The theme file
A theme is one JSON file. Start from a built-in with extends and override only what changes: colours per mode, a few tokens, and recipes for the parts you want to look different.
{
"$schema": "https://ui.exponential.at/schemas/theme/v1.json",
"id": "brand",
"name": "Brand",
"extends": "neutral",
"modes": {
"light": {
"color": { "primary": "#4f46e5", "primaryForeground": "#ffffff", "ring": "#6366f1" }
},
"dark": {
"color": { "primary": "#818cf8", "primaryForeground": "#1e1b4b", "ring": "#818cf8" }
}
},
"tokens": {
"radius": { "md": 10, "lg": 14 },
"type": { "family": { "sans": "Inter" } }
},
"fonts": {
"Inter": { "fallback": "ui-sans-serif, system-ui", "weights": [400, 500, 600, 700], "source": "host" }
},
"recipes": {
"Button": {
"root": [
{ "style": { "borderRadius": "$radius.full" } },
{ "when": { "variant": "outline", "state": "hover" }, "style": { "backgroundColor": "$color.accent" } }
]
},
"Card": {
"root": [{ "style": { "borderWidth": 0, "boxShadow": "$shadow.md" } }]
}
}
}The $schema gives your editor completion and validation (theme.schema.json).
Tokens
Tokens are the named values every component uses: $color.primary, $spacing.md, $radius.lg, type sizes and line heights, control heights, shadows, borders and motion. Colours and shadows live under modes.light and modes.dark; everything else under tokens. Colours are #rrggbb or #rrggbbaa only; the theme builder converts oklch, hsl and rgb when it imports a shadcn globals.css.
Fonts are referenced by family name. Each platform registers the files (the host's job), and fonts says what to fall back to.
Recipes
A recipe styles one part of a component (Button.root, Switch.thumb, Tabs.tab) as a list of rules. A rule applies when every entry of its when matches: recipe props like variant and size, and states (hover, pressed, focus, disabled, checked, open, selected).
Rules merge by specificity: one point per condition, later wins on ties. A child theme's base rule therefore never hides its parent's hover or variant rules. The Themes page lists every component's parts, and the builder shows them live.
Load it
Themes load at runtime, so the file can come from your bundle, a URL or a vapp package. Every loader flattens the extends chain and reports every problem at once (an unknown token, a key outside the whitelist, a misspelled part) without crashing.
| Platform | Load |
|---|---|
| React | <HostSurface theme={brandJson} … /> |
| TypeScript | validateTheme(json) · loadTheme(json, { themes: BUILTIN_THEMES }) |
| SwiftUI | ThemeHandle.load(json:) · model.setTheme(…) |
| Compose | ThemeHandle.load(json, parents) · model.setTheme(…) |
| gpui / Rust | exponential_ui::theme::load_theme(&json, &options) |
Compiled in CI
Every file on this page is a real file in apps/ui-site/guides/theme. CI copies it into an empty directory and builds it from scratch, the way you would, so the example cannot drift from the SDK:
bun apps/ui-site/guides/check.ts themeFor a complete app on this platform, see the samples: four hosts rendering the same surface from a local A2UI server, in a third-party theme, with a custom extension component.