Craftkitdocs

Styling & themes

Brand the embed for your customers — variables, rules, presets, and four delivery channels.

11 — Styling the Craftkit Embed

The Craftkit embed runs in an iframe. Cross-origin iframes don't inherit the host page's CSS, so the styling contract is JSON, not CSS. Any host framework that can produce JSON can brand the embed — React, Vue, Svelte, Angular, Astro, Solid, vanilla JS, server-rendered HTML, Rails partials, Laravel Blade, ASP.NET Razor, you name it.

There are four channels the host can use to deliver styling. They all share one schema (Appearance) and they compose with documented precedence — pick whichever channel(s) match your stack.


TL;DR

// The Appearance object — the entire styling contract.
{
  "baseTheme": "light",                 // 'light' | 'dark' | 'auto' | 'shadcn'
  "variables": {
    "colorPrimary": "#0EA5E9",
    "colorPrimaryForeground": "#ffffff",
    "borderRadius": "12px",
    "fontFamily": "'Inter', system-ui, sans-serif"
    // …17 token keys total — see "Variables" below
  },
  "rules": {                            // surgical CSS overrides
    ".ck-publish-button": { "boxShadow": "0 1px 2px rgba(0,0,0,0.06)" },
    ".ck-publish-button:hover": { "boxShadow": "0 4px 12px rgba(14,165,233,0.25)" }
  },
  "layout": {                           // structural toggles
    "showCloseButton": false,
    "density": "compact",
    "locale": "es"
  },
  "stylesheetUrl": "https://app.acme.com/embed-brand.css",
  "fontUrl": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap",
  "logoUrl": "https://app.acme.com/logo.svg"
}

Channels

1. Mint-time (server-to-server, framework-free)

Embed the appearance object in your call to POST /v1/embed/sessions. It travels in the JWT and is applied before first paint. No frontend code required on your side.

POST /v1/embed/sessions
Authorization: Bearer ck_live_…
Content-Type: application/json

{
  "tenant":   { "externalId": "acct_42",  "displayName": "Acme Corp" },
  "actor":    { "externalId": "user_99",  "email": "ops@acme.com" },
  "scope":    { "mode": "edit" },
  "appearance": {
    "baseTheme": "light",
    "variables": { "colorPrimary": "#0EA5E9", "borderRadius": "12px" }
  }
}

This works from any backend. The shape is JSON. Your backend's framework is irrelevant — Node, Rails, Laravel, .NET, Python, Go, Elixir, all identical.

2. Mount-time (URL params)

For partners whose frontend chooses styling but whose backend can't. Append ?theme=… or ?appearance=<base64-json> to the iframe src.

<iframe src="https://embed.craftkit.dev/embed/builder?session_token=…&theme=dark"></iframe>
// JSON object → URL-safe base64
const appearance = { baseTheme: 'dark', variables: { colorPrimary: '#0EA5E9' } };
const b64 = btoa(JSON.stringify(appearance))
  .replace(/\+/g, '-').replace(/\//g, '_');
iframe.src = `${baseUrl}?session_token=${token}&appearance=${b64}`;

URL params override mint-time appearance. Useful for live light/dark toggles where you don't want to re-mint a session.

3. Runtime (postMessage)

For live updates after mount: switching themes when the user toggles dark mode, updating brand color from a settings page, swapping density. Same call from any framework:

iframe.contentWindow.postMessage(
  {
    type: 'craftkit.appearance.set',
    version: 1,
    payload: {
      appearance: { baseTheme: 'dark', variables: { colorPrimary: '#7C3AED' } }
    }
  },
  'https://embed.craftkit.dev'   // targetOrigin — required for security
);

The iframe validates the sender origin against your registered embed_origin rows, then merges the partial appearance on top of the mint-time baseline and re-applies CSS without re-rendering. It acks back with:

window.addEventListener('message', (e) => {
  if (e.data?.type === 'craftkit.appearance.applied') {
    console.log('iframe accepted:', e.data.payload.acceptedKeys);
  }
});

4. Partner stylesheet (CSS escape hatch)

For partners that already maintain a brand stylesheet they don't want to translate to JSON. Set appearance.stylesheetUrl (mint-time, URL, or runtime) — the embed loads it as a <link rel="stylesheet"> inside the iframe, after our defaults so partner CSS wins.

{
  "stylesheetUrl": "https://app.acme.com/embed-brand.css"
}
/* https://app.acme.com/embed-brand.css */
.ck-embed-root {
  --primary: #0EA5E9;
  --radius: 12px;
}
.ck-publish-button {
  font-weight: 600;
}

Origin gate. The stylesheet URL must be HTTPS and on a host you've registered as an embed origin. Anything else is silently dropped — no error, just no custom CSS — so a typo never breaks the embed.


Precedence

defaults                    ←  embed.css ships these
  ↓
baseTheme                   ←  appearance.baseTheme: 'light' | 'dark' | …
  ↓
branding (legacy)           ←  back-compat: primaryColor, fontUrl, logoUrl
  ↓
project default appearance  ←  embed_partner.default_appearance
                              (Dashboard → Embed → Themes)
  ↓
mint-time appearance        ←  JWT.ck.appearance (per-session override)
  ↓
URL appearance              ←  ?theme= / ?appearance=
  ↓
runtime appearance          ←  postMessage('craftkit.appearance.set', …)
  ↓
partner stylesheet          ←  appearance.stylesheetUrl (cascades last)

Each step is optional. A partner that sets none gets the neutral default theme — system fonts, slate primary, a Stripe-y look that's intentionally generic so it blends into any host.


Variables (appearance.variables)

JSON keys map 1:1 to CSS custom properties on .ck-embed-root. Every key is optional. Values are plain CSS strings — hex, rgb(), hsl(), system keywords, anything CSS understands.

JSON key CSS variable Default Purpose
colorPrimary --primary slate-900 Buttons, focus rings, link accents
colorPrimaryForeground --primary-foreground white Text on primary backgrounds
colorBackground --background white Page surface
colorForeground --foreground slate-900 Body text
colorMuted --muted slate-100 Subdued surfaces (banner bg)
colorMutedForeground --muted-foreground slate-500 Subdued text
colorAccent --accent slate-100 Hover state surface
colorAccentForeground --accent-foreground slate-900 Text on accent surface
colorBorder --border slate-200 Outlines
colorInput --input slate-200 Form-field outlines
colorRing --ring slate-900 Focus ring color
colorDanger --destructive red-500 Error states
colorSuccess --success emerald-500 Success states
colorWarning --warning amber-500 Warning states
fontFamily --font-sans system Body font stack
fontFamilyMono --font-mono system mono Code/key font
fontSizeBase --font-size-base 14px Root font size
fontWeightNormal --font-weight-normal 400 Body weight
fontWeightMedium --font-weight-medium 500 Button/heading weight
fontWeightBold --font-weight-bold 600 Strong text
borderRadius --radius 0.5rem Corner radius
spacingUnit --spacing-unit 0.25rem Spacing scale base

Rules (appearance.rules)

For surgical overrides where a variable change isn't enough. CSS-like selector → camelCase property map.

{
  "rules": {
    ".ck-publish-button": { "boxShadow": "0 1px 2px rgba(0,0,0,0.06)" },
    ".ck-publish-button:hover": { "boxShadow": "0 4px 12px rgba(14,165,233,0.25)" },
    ".ck-input": { "fontWeight": "500" },
    ".ck-input:focus": { "borderColor": "#0EA5E9" }
  }
}

Selector allowlist. Only published .ck-* classes are valid (see 07-admin-ui.md). Optional state suffixes: :hover, :focus, :focus-visible, :active, :disabled, ::placeholder, --selected, --invalid. Anything else is silently dropped.

Property allowlist. Color, typography, padding/margin, border, border-radius, box-shadow, opacity, cursor, transition. Anything else (position, z-index, display, content, pointer-events, …) is silently dropped — those properties could break the iframe layout or escape the widget surface.

Value sanitization. Values that contain <, expression(, javascript:, @import, or unbalanced parens are silently dropped.


Layout (appearance.layout)

Structural toggles, independent of color/type.

Key Type Purpose
showTopBar boolean Show/hide the top bar
showCloseButton boolean Show/hide the embed close button
showPublishButton boolean Show/hide the publish action
showSaveDraftButton boolean Show/hide the save-draft action
showTemplateList boolean Show/hide the template-list rail
showRenderHistory boolean Show/hide the render-history rail
density 'comfortable' | 'compact' Spacing scale
locale string UI locale, e.g. en, es, de-DE

Per-framework recipes

The same JSON, four ways. None require shadcn or Tailwind.

Vanilla HTML / plain JS

<iframe id="ck"
        src="https://embed.craftkit.dev/embed/builder?session_token=…"
        style="width:100%;height:100%;border:0"></iframe>

<script>
  // Runtime theme toggle — works in any framework or no framework.
  document.querySelector('#dark-toggle').addEventListener('click', () => {
    document.getElementById('ck').contentWindow.postMessage(
      {
        type: 'craftkit.appearance.set',
        version: 1,
        payload: { appearance: { baseTheme: 'dark' } }
      },
      'https://embed.craftkit.dev'
    );
  });
</script>

React / Next.js

function CraftkitEmbed({ sessionToken, appearance }) {
  const ref = useRef<HTMLIFrameElement>(null);

  useEffect(() => {
    if (!appearance) return;
    ref.current?.contentWindow?.postMessage(
      { type: 'craftkit.appearance.set', version: 1, payload: { appearance } },
      'https://embed.craftkit.dev',
    );
  }, [appearance]);

  return (
    <iframe
      ref={ref}
      src={`https://embed.craftkit.dev/embed/builder?session_token=${sessionToken}`}
      style={{ width: '100%', height: '100%', border: 0 }}
    />
  );
}

Vue 3

<script setup>
import { ref, watch } from 'vue';
const props = defineProps(['sessionToken', 'appearance']);
const ifr = ref(null);
watch(() => props.appearance, (a) => {
  ifr.value?.contentWindow?.postMessage(
    { type: 'craftkit.appearance.set', version: 1, payload: { appearance: a } },
    'https://embed.craftkit.dev'
  );
});
</script>

<template>
  <iframe ref="ifr"
          :src="`https://embed.craftkit.dev/embed/builder?session_token=${sessionToken}`"
          style="width:100%;height:100%;border:0" />
</template>

Svelte

<script>
  export let sessionToken;
  export let appearance;
  let ifr;
  $: if (ifr && appearance) {
    ifr.contentWindow.postMessage(
      { type: 'craftkit.appearance.set', version: 1, payload: { appearance } },
      'https://embed.craftkit.dev'
    );
  }
</script>

<iframe bind:this={ifr}
        src="https://embed.craftkit.dev/embed/builder?session_token={sessionToken}"
        style="width:100%;height:100%;border:0" />

Angular

@Component({
  selector: 'craftkit-embed',
  template: `<iframe #ifr [src]="src | safe" style="width:100%;height:100%;border:0"></iframe>`,
})
export class CraftkitEmbed implements OnChanges {
  @Input() sessionToken!: string;
  @Input() appearance?: Appearance;
  @ViewChild('ifr') ifr!: ElementRef<HTMLIFrameElement>;

  get src() {
    return `https://embed.craftkit.dev/embed/builder?session_token=${this.sessionToken}`;
  }
  ngOnChanges(c: SimpleChanges) {
    if (c.appearance && this.ifr) {
      this.ifr.nativeElement.contentWindow?.postMessage(
        { type: 'craftkit.appearance.set', version: 1, payload: { appearance: this.appearance } },
        'https://embed.craftkit.dev'
      );
    }
  }
}

Astro / static site

---
const { sessionToken } = Astro.props;
---
<iframe id="ck"
        src={`https://embed.craftkit.dev/embed/builder?session_token=${sessionToken}&theme=auto`}
        style="width:100%;height:100%;border:0"></iframe>

The ?theme=auto URL param has the iframe follow the user's OS prefers-color-scheme — works without any client JS.

Rails / Laravel / Django (server-rendered)

Server-renders the iframe with mint-time appearance baked in:

<%# Rails: assume @session was minted with appearance already %>
<iframe src="<%= @session.iframe_url %>" style="width:100%;height:100%;border:0"></iframe>

The mint API call:

# Rails — same call from any backend language
Faraday.post(
  "#{ENV['CRAFTKIT_API']}/v1/embed/sessions",
  {
    tenant:  { externalId: account.id, displayName: account.name },
    actor:   { externalId: current_user.id, email: current_user.email },
    appearance: {
      baseTheme: 'light',
      variables: {
        colorPrimary: account.brand_color,
        fontFamily: "'Inter', system-ui, sans-serif",
        borderRadius: '10px',
      },
    },
  }.to_json,
  { 'Authorization' => "Bearer #{ENV['CK_API_KEY']}", 'Content-Type' => 'application/json' },
)

shadcn / Tailwind hosts (opt-in)

If your host is built on shadcn/ui, you already define --primary, --background, --ring, --radius, etc. on <html>. Cross-origin iframes can't read those, so a 12-line snippet on your host mirrors them into the embed via postMessage:

function pushShadcnAppearance(iframe) {
  const cs = getComputedStyle(document.documentElement);
  const v = (k) => cs.getPropertyValue(k).trim();
  iframe.contentWindow.postMessage({
    type: 'craftkit.appearance.set',
    version: 1,
    payload: {
      appearance: {
        baseTheme: 'shadcn',
        variables: {
          colorPrimary:           v('--primary'),
          colorPrimaryForeground: v('--primary-foreground'),
          colorBackground:        v('--background'),
          colorForeground:        v('--foreground'),
          colorMuted:             v('--muted'),
          colorMutedForeground:   v('--muted-foreground'),
          colorAccent:            v('--accent'),
          colorAccentForeground:  v('--accent-foreground'),
          colorBorder:            v('--border'),
          colorInput:             v('--input'),
          colorRing:              v('--ring'),
          colorDanger:            v('--destructive'),
          borderRadius:           v('--radius'),
        }
      }
    }
  }, 'https://embed.craftkit.dev');
}

document.querySelector('#ck').addEventListener('load', (e) => pushShadcnAppearance(e.target));

This is the shadcn equivalent of Clerk's baseTheme: shadcn preset, adapted for cross-origin iframes (which can't share CSS variables natively).


Security model

  • Origin-gated postMessage. The iframe rejects every postMessage from an origin not registered in your partner's embed_origin table.
  • Selector + property allowlists. Rules can only target our published .ck-* classes with whitelisted properties. Layout-killing properties (position, z-index, display, pointer-events, content) are silently dropped.
  • Value sanitizer. Values containing <, javascript:, expression(, @import, or unbalanced parens are dropped.
  • CSP-gated stylesheetUrl. <link href> only loads if the URL is HTTPS and on a registered embed origin. Drive-by injection attempts fail closed.
  • No JS injection. appearance.rules cannot define behavior: or expression() (IE-era CSS exec); modern browsers don't execute these anyway, but the sanitizer drops them defensively.

See also

  • 05 — postMessage Protocol — full event catalogue including craftkit.appearance.set / applied.
  • 07 — Admin UI — full .ck-* class catalogue with DOM context, the stable contract you can target via rules.