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_origintable. - 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.rulescannot definebehavior:orexpression()(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 viarules.