Guide 5 of 9

Write your own theme

Tokens, recipes and an extends chain: one JSON file every renderer loads at runtime.

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.

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

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.

PlatformLoad
React<HostSurface theme={brandJson} … />
TypeScriptvalidateTheme(json) · loadTheme(json, { themes: BUILTIN_THEMES })
SwiftUIThemeHandle.load(json:) · model.setTheme(…)
ComposeThemeHandle.load(json, parents) · model.setTheme(…)
gpui / Rustexponential_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:

shell
bun apps/ui-site/guides/check.ts theme

For 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.

Next guideWrite an extension