Skip to content

Write your own theme

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.

guides/theme/brand.theme.json
{
"$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 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.

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 keeps its parent’s hover and variant rules in force. The Themes reference lists every component’s parts, and the theme builder shows them live.

Themes load at runtime, so the file can come from your bundle, a URL or an app package. Every loader flattens the extends chain and reports every problem at once (an unknown token, a key outside the whitelist, a misspelled part) and keeps running.

Platform Load
React <HostSurface theme={brandJson} … />
Solid <ExponentialSurface theme={brandJson} … />
Web component <exponential-surface theme="orange"> · el.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 eui_core::theme::load_theme(&json, &options)

bun packages/exponential-ui/guides/check.ts theme runs validateTheme and loadTheme over guides/theme and the built-ins and fails on any issue.