Skip to content

End-to-end theming

Build and apply a TypeStyles theme — tokens, createTheme, override, and mount

A complete theming setup with TypeStyles: design tokens, a theme surface class, recipe overrides, and mounting in the DOM. Consumers can customize with TypeStyles APIs or plain CSS that targets the same class names and custom properties.

For more patterns (multi-brand, condition scopes, @property animation), see Theming patterns. Coming from StyleX / Panda / vanilla-extract / Stitches? See Migration.

What you get

Piece CSS / DOM
tokens.create / declare :root { --app-color-… }
tokens.createTheme({ name, … }) .theme-app-brand { --app-color-… } (+ optional light-dark(), @media, attrs)
recipe Readable recipe classes (button--intent-primary)
override(…, { selectorPrefix }) .theme-app-brand .button { … }
Plain CSS Same class and --* names — consumers can theme without TypeStyles

Walkthrough

Defaults live on :root via tokens.create. Themes only set what changes under a theme class — brand accents, theme-local namespaces, recipe overrides. Unchanged paths inherit through the cascade.

Light and dark belong on the token leaves ({ light, dark }) when you register colorModes — not as parallel untyped constants. Passing { decl } types those values against the schema.

1. Shared runtime

ts
// typestyles.ts
import { colorModes, createTypeStyles } from 'typestyles';

export const { recipe, tokens, override } = createTypeStyles({
  scopeId: 'app',
  colorModes,
  layers: {
    order: ['tokens', 'components', 'overrides'] as const,
    token: 'tokens',
    style: 'components',
  },
});

2. Color tokens

ts
// tokens/color.ts
import { atProperty } from 'typestyles';
import { tokens } from '../typestyles';

const colorDecl = tokens.declare('color', {
  text: atProperty.color,
  surface: atProperty.color,
  accent: { default: atProperty.color },
});

/** `:root` defaults — mode-aware leaves compile to `light-dark()` when `colorModes` is set. */
export const color = tokens.create(
  'color',
  {
    text: { light: '#111827', dark: '#e5e7eb' },
    surface: { light: '#ffffff', dark: '#0f172a' },
    accent: { default: { light: '#2563eb', dark: '#60a5fa' } },
  },
  { decl: colorDecl },
);

atProperty.color is { syntax: '<color>', initial: 'transparent' } — spread or override per leaf ({ ...atProperty.color, inherits: false }). Same presets exist for length, time, angle, and more; see CSS primitives — atProperty presets.

color.text / color.accent.default are typed var(--…) refs (syntax-branded). Use them in recipes — do not feed them back into createTheme({ tokens }) as values.

3. Button recipe

ts
// components/button.ts
import { recipe } from '../typestyles';
import { color } from '../tokens/color';

export const button = recipe(
  'button',
  {
    base: {
      padding: '8px 16px',
      borderRadius: '6px',
      border: 'none',
      cursor: 'pointer',
      backgroundColor: color.accent.default,
      color: color.text,
    },
    variants: {
      intent: {
        primary: {},
        ghost: {
          backgroundColor: 'transparent',
          color: color.accent.default,
          border: `1px solid ${color.accent.default}`,
        },
      },
    },
    defaultVariants: { intent: 'primary' },
  },
  { layer: 'components' },
);

4. Brand theme

ts
// themes/brand.ts
import { tokens } from '../typestyles';
// Recipe must be registered before createTheme({ components })
import '../components/button';

/**
 * Theme-class deltas only. Root still supplies text/surface via cascade;
 * this surface retints accent and adds a theme-local `brand` namespace.
 */
export const brand = tokens.createTheme({
  name: 'brand',
  tokens: {
    color: {
      accent: { default: { light: '#7c3aed', dark: '#a78bfa' } },
    },
    brand: {
      glow: { default: '#a78bfa' },
    },
  },
  // Same as override(button, …, { selectorPrefix: `.${brand.className}` })
  components: {
    button: ({ tokens: t }) => ({
      base: { boxShadow: `0 0 0 3px ${t.brand.glow.default}` },
    }),
  },
});

brand.className is something like theme-app-brand. brand.tokens.brand.glow.default is a var(--…) string — the same shape as tokens.use('color'). Define additional themes the same way and switch by swapping className on a parent — see Multi-brand theming.

Side-effect-import these modules from your typestyles entry so extraction sees every registration.

Optional: override without components

ts
// themes/brand-overrides.ts
import { override } from '../typestyles';
import { button } from '../components/button';
import { brand } from './brand';

override(
  button,
  { variants: { intent: { ghost: { textDecoration: 'underline' } } } },
  { selectorPrefix: `.${brand.className}`, layer: 'overrides' },
);

Sharing one root theme across many brands? Use Theme.override.

Mount (React)

Root mode-aware tokens compile to light-dark(). With colorModes configured, createTheme also emits color-scheme: light dark on the theme class. For an explicit data-mode toggle, see condition scopes or attribute-driven modes.

tsx
// App.tsx
import { button } from './components/button';
import { brand } from './themes/brand';

export function App() {
  return (
    <div className={brand.className}>
      <button type="button" className={button()}>
        Primary
      </button>
      <button type="button" className={button({ intent: 'ghost' })}>
        Ghost
      </button>
    </div>
  );
}

Mount (HTML)

html
<div class="theme-app-brand">
  <button class="button button--intent-primary">Primary</button>
</div>

Or override tokens from plain CSS (no TypeStyles in the consumer app):

css
.theme-app-brand {
  --app-color-accent-default: #059669;
}
.theme-app-brand .button--intent-primary {
  border-radius: 999px;
}

Selector prefix convention

Theme-scoped recipe restyles use a descendant prefix (not CSS @scope):

ts
override(
  button,
  { base: { borderRadius: '999px' } },
  {
    selectorPrefix: `.${brand.className}`, // → ".theme-app-brand .button { … }"
    layer: 'overrides',
  },
);

Prefer this (or createTheme({ components })) over hand-written class strings. Cascade layers keep override order predictable — see Cascade layers.

Need API
Deep-merge override trees / leaf refs mergeThemeOverrides
Fork a root theme for brands / apps Theme.override
Media / attr / class conditions tokens.when, tokens.colorMode.*
Zero-runtime extract of all recipes getRegisteredComponentRefs + Vite extract.registeredComponentsModule

Checklist for design-system authors

  1. Export a shared createTypeStyles({ scopeId, colorModes }) runtime.
  2. tokens.declare the schema, then tokens.create(…, { decl }) with mode-aware { light, dark } leaves for defaults.
  3. Register recipes with recipe on that same runtime.
  4. Document that apps call tokens.createTheme({ name, tokens, … }) with overrides only, then apply theme.className.
  5. Document selectorPrefix: \.${theme.className}`(or themecomponents`) for recipe restyles.
  6. Keep class and --* names stable so consumers can also theme from plain CSS.