

# Themes & Tokens

Name shared design values once, use them in every style, and change them for a color scheme, a brand, or one section of a page.

## Overview

A token names a design decision, such as `accent` for a brand color. A theme applies token values through inherited custom properties, so switching it never changes component classes. Color tokens can hold light and dark values, so themes and color schemes switch independently.

Most tasks in this guide use four groups of functions. The first two come from `zyzz`, and the others come from the config that `defineConfig` returns:

* **`defineVars(…)` and `extendVars(…)`:** Typed token sets. An extension overrides some values and keeps the rest.
* **`defineConfig(…)`:** Binds sets to `style`, so properties accept token names.
* **`vars(…)`:** Selects a set and a color scheme for an element and its descendants.
* **`appearance.set(…)` and `script()`:** Save a document preference and restore it before the first paint.

> [!TIP]
> Tokens hold values shared across an application. A value that one component owns, such as a plan's accent color, fits a [CSS variable](/docs/guides/styling). A fixed list of choices, such as button sizes, fits [Variants](/docs/guides/variants).

A config module binds the tokens to `style`, and `vars()` applies them to a section that follows the system color scheme:

```ts title="zyzz.config.ts"
import { defineConfig, defineVars } from 'zyzz'

const base = defineVars({
  color: {
    accent: { dark: '#60a5fa', light: '#2563eb' },
    surface: { dark: '#111111', light: '#ffffff' },
  },
  spacing: { page: '1.5rem' },
})

export const { style, vars } = defineConfig({ vars: base })
```

```tsx title="Card.tsx"
import { style, vars } from './zyzz.config.js'

export function Card() {
  return (
    <section {...vars({ colorScheme: 'light dark' })}>
      <article {...styles.card()}>
        <h2 {...styles.title()}>Account</h2>
      </article>
    </section>
  )
}

namespace styles {
  // Token names compile to CSS variable references
  export const card = style({ backgroundColor: 'surface', padding: 'page' })

  export const title = style({ color: 'accent' })
}
```

## Walkthrough

The steps below define tokens, use them in a card, and apply a theme to the document. They target React on the web and assume the compiler setup from [Getting Started](/docs/introduction/getting-started). [React Native](/docs/guides/native) selects themes without CSS inheritance.

### Define Tokens

Pass a `defineVars` set to `defineConfig`, and export the helpers from one config module. Styles that import `style` from that module accept the token names.

```ts title="zyzz.config.ts"
import { defineConfig, defineVars } from 'zyzz'

// 1. Define tokens, grouped by category
const base = defineVars({
  color: {
    // A pair holds one value per color scheme
    accent: { dark: '#60a5fa', light: '#2563eb' },
    foreground: { dark: '#fafafa', light: '#171717' },
    surface: { dark: '#111111', light: '#ffffff' },
  },
  spacing: { page: '1.5rem' },
})

// 2. Bind the tokens to the exported helpers
export const { style, vars } = defineConfig({ vars: base })
```

A `{ dark, light }` pair holds one color per scheme, and a plain value applies to both. A set's top-level keys decide which properties accept its tokens. For example, `color` tokens apply to `color` and `backgroundColor`, and `spacing` tokens apply to padding, margin, gap, and sizes.

| Key | CSS properties |
| --- | --- |
| `animate` | `animation` |
| `aspect` | `aspectRatio` |
| `borderWidth` | `border*Width`, `borderWidth` |
| `breakpoint` | `@media` |
| `color` | `accentColor`, `backgroundColor`, `border*Color`, `caretColor`, `color`, `columnRuleColor`, `fill`, `floodColor`, `lightingColor`, `outlineColor`, `stopColor`, `stroke`, `strokeColor`, `textDecorationColor`, `textEmphasisColor` |
| `container` | `@container`, `columns`, `flexBasis`, `inlineSize`, `maxInlineSize`, `maxWidth`, `minInlineSize`, `minWidth`, `width` |
| `ease` | `transitionTimingFunction` |
| `fontFamily` | `fontFamily` |
| `fontSize` | `fontSize` |
| `fontWeight` | `fontWeight` |
| `letterSpacing` | `letterSpacing` |
| `lineHeight` | `lineHeight` |
| `radius` | `border*Radius`, `borderRadius` |
| `shadow` | `boxShadow` |
| `spacing` | `blockSize`, `borderSpacing`, `bottom`, `columnGap`, `flexBasis`, `gap`, `height`, `inlineSize`, `inset*`, `left`, `margin*`, `maxBlockSize`, `maxHeight`, `maxInlineSize`, `maxWidth`, `minBlockSize`, `minHeight`, `minInlineSize`, `minWidth`, `padding*`, `right`, `rowGap`, `scrollMargin*`, `scrollPadding*`, `textIndent`, `top`, `translate`, `width` |
| `textShadow` | `textShadow` |
| `typography` | `fontFamily`, `fontSize`, `fontWeight`, `letterSpacing`, `lineHeight` |

A `*` stands for longhands, so `margin*` covers properties such as `marginTop` and `marginInline`.

A key named after a property, such as `padding` or `zIndex`, applies only to that property and takes precedence over a shared key such as `spacing`. [Category fallbacks](/docs/api/core/defineConfig#category-fallbacks) documents the full lookup order.

### Reference Tokens

Write token names as values. Each name compiles to a reference to its CSS variable.

```tsx title="Card.tsx"
import { style } from './zyzz.config.js'

export function Card() {
  return (
    <article {...styles.card()}>
      <h2 {...styles.title()}>Account</h2>
      <p>Manage account preferences.</p>
    </article>
  )
}

namespace styles {
  export const card = style({
    // Token names compile to CSS variable references
    backgroundColor: 'surface',
    borderRadius: '12px',
    color: 'foreground',
    // maxWidth accepts spacing tokens, so a literal takes !custom
    maxWidth: '28rem !custom',
    padding: 'page',
  })

  export const title = style({ color: 'accent', fontSize: '1.25rem' })
}
```



`maxWidth` accepts spacing tokens, so the literal `28rem` takes the ` !custom` suffix. The compiler removes the suffix and still checks the CSS value. `borderRadius` has no configured tokens in this config, so it accepts CSS values directly.

Configured names take precedence over CSS keywords, so with a color token named `red`, `color: 'red'` selects the token and `'red !custom'` selects the CSS color. A value outside the scale fails type checking at the declaration:

```ts
import { defineConfig } from 'zyzz'

const { style } = defineConfig({ vars: { spacing: { page: '1.5rem' } } })

style({ padding: '18px' })
// error: Type '"18px"' is not assignable to type '"18px" & Expected<"page" | `${string} !custom`>'.
// Type 'string' is not assignable to type 'Expected<"page" | `${string} !custom`>'.
```

### Apply a Theme

Spread `vars()` on the root element. Its `colorScheme` option sets the CSS `color-scheme` property, and `light dark` follows the operating system preference.

```tsx title="Document.tsx"
import type { ReactNode } from 'react'
import { vars } from './zyzz.config.js'

export function Document(props: Document.Props) {
  return (
    // Scope the document and follow the system color scheme
    <html lang="en" {...vars({ colorScheme: 'light dark' })}>
      <body>{props.children}</body>
    </html>
  )
}

export declare namespace Document {
  type Props = { children: ReactNode }
}
```

`vars(…)` returns a scope class and an inline `color-scheme`, and creates no CSS at runtime. A change to the operating system preference applies immediately, with no listener, because the browser resolves `light-dark()` on every style recalculation.

Outside any scope, references fall back to the values from `defineVars`. Color pairs then resolve to their light value unless an ancestor sets `color-scheme`.

## Recipes

### Use Token References

`vars` holds a typed reference for every token. A reference fits any property with a compatible value, including CSS expressions:

```tsx title="Sidebar.tsx"
import { style, vars } from './zyzz.config.js'

export function Sidebar() {
  return <aside {...styles.sidebar()}>Navigation</aside>
}

namespace styles {
  export const sidebar = style({
    // An explicit reference fits inside a CSS expression
    height: `calc(100dvh - ${vars.spacing.page} * 2) !custom`,
    padding: 'page',
  })
}
```

### Add a Theme

`extendVars` derives a set that shares every token path with its base and replaces only the listed values. Register the sets by name, and name the default with `defaultVars`.

```ts title="zyzz.config.ts"
import { defineConfig, defineVars, extendVars } from 'zyzz'

// 1. Define the base set
const base = defineVars({
  color: {
    accent: { dark: '#60a5fa', light: '#2563eb' },
    foreground: { dark: '#fafafa', light: '#171717' },
    surface: { dark: '#111111', light: '#ffffff' },
  },
  spacing: { page: '1.5rem' },
})

// 2. Override some values and keep the rest from base
const alternate = extendVars(base, {
  color: { accent: { dark: '#c084fc', light: '#9333ea' } },
  spacing: { page: '2rem' },
})

// 3. Register both sets and choose the default
export const { appearance, script, style, vars } = defineConfig({
  defaultVars: 'base',
  vars: { alternate, base },
})
```

```tsx title="ThemePreview.tsx"
import { Card } from './Card.js'
import { vars } from './zyzz.config.js'

export function ThemePreview() {
  return (
    // 1. Apply the default set at the root
    <main {...vars()}>
      <Card />
      <section
        // 2. Switch this subtree to the alternate set
        {...vars({ set: 'alternate' })}
      >
        <Card />
        <section
          // 3. Return to base and fix the light scheme
          {...vars({ colorScheme: 'light', set: 'base' })}
        >
          <Card />
        </section>
      </section>
    </main>
  )
}
```



`ThemePreview` reuses the `Card` from the walkthrough. The first card uses base values and the second uses alternate values. The nested card returns to base values in the light scheme, because the nearest scope supplies each value. Omitted options inherit differently:

* **`set`:** Selects `defaultVars` rather than an outer set.
* **`colorScheme`:** Keeps the outer scheme.

Every set keeps the token paths and value domains of its base. A new path or a value from another domain fails type checking:

```ts
import { defineVars, extendVars } from 'zyzz'

const base = defineVars({
  color: { accent: '#2563eb' },
  spacing: { page: '1.5rem' },
})

extendVars(base, { color: { brand: '#9333ea' } })
// error: Object literal may only specify known properties, and 'brand' does not exist in type 'Overrides<{ readonly accent: "#2563eb"; }>'.
extendVars(base, { spacing: { page: '#9333ea' } })
// error: Type '"#9333ea"' is not assignable to type 'Conditional<Length | Reference<"spacing"> | Composition<"spacing">> | undefined'.
```

### Force a Scheme

Pass `colorScheme` to fix the scheme of one section, such as a dark sidebar on a light page. The scheme and the set are separate options.

```tsx title="DarkPreview.tsx"
import { Card } from './Card.js'
import { vars } from './zyzz.config.js'

export function DarkPreview() {
  return (
    // Fix the dark scheme regardless of the page scheme
    <section {...vars({ colorScheme: 'dark', set: 'alternate' })}>
      <Card />
    </section>
  )
}
```



* **`light` or `dark`:** Fixes the scheme for the section.
* **`light dark`:** Follows the operating system preference.
* **Omitted:** Inherits the surrounding scheme.

Browser-drawn parts such as form controls and scrollbars inside the section follow the same `color-scheme`.

### Switch Themes

`appearance.set()` applies a set and a scheme to the document root and saves them. `appearance.get()` reads the current root selection as `{ colorScheme, set }`.

```tsx title="AppearanceButton.tsx"
import { appearance } from './zyzz.config.js'

export function AppearanceButton() {
  return (
    <button
      // Apply the selection to the document root and save it
      onClick={() => appearance.set({ colorScheme: 'dark', set: 'alternate' })}
      type="button"
    >
      Use alternate dark colors
    </button>
  )
}
```



The selection persists in `localStorage` under the config's `storageKey`, which defaults to `'zyzz'`. Blocked storage still applies the selection to the current document. `colorScheme: undefined` clears the scheme and saves the cleared state.

`appearance` changes only the document root, while `vars` creates local scopes. The helper does not notify subscribers, so a control that displays the current choice keeps its own state. Single-set configs accept only `colorScheme`.

### Prevent Theme Flicker

A preference saved by `appearance` applies only after application JavaScript runs. `script()` returns a small inline script that reapplies it before the first paint. Generate the script on the server or at build time, and place it early in `<head>`.

```tsx title="Document.tsx"
import type { ReactNode } from 'react'
import { script, vars } from './zyzz.config.js'

export function Document(props: Document.Props) {
  return (
    <html
      lang="en"
      // The script changes root attributes before hydration
      suppressHydrationWarning
      // Server markup keeps the default selection
      {...vars({ colorScheme: 'light dark' })}
    >
      <head>
        <script
          // Reapply the saved preference before the first paint
          dangerouslySetInnerHTML={{ __html: script() }}
          // Allow the inline script under a content security policy
          nonce={props.nonce}
        />
      </head>
      <body>{props.children}</body>
    </html>
  )
}

export declare namespace Document {
  type Props = { children: ReactNode; nonce?: string | undefined }
}
```

The server markup keeps the default selection, and the script replaces it only when storage holds a valid preference. Missing, malformed, or blocked storage leaves the server markup intact.

* **Timing:** The script must run synchronously, so it takes no `async`, `defer`, or `type="module"`.
* **CSP:** Pass the application's nonce, or allow a hash of the generated source.
* **Hydration:** `suppressHydrationWarning` covers the root attributes that the script changes before React hydrates.

[Config Script](/docs/api/core/defineConfig/script) documents the stored record and framework constraints.

### Use Bundled Tokens

`zyzz/default` exports a ready config with Geist colors and typography, plus Tailwind spacing, radius, shadow, and query scales. The root `zyzz` entrypoint stays token-free. The [Variables explorer](/vars) lists every bundled value.

```tsx title="AccountCard.tsx"
// Geist colors and typography, plus Tailwind scales
import { style, vars } from 'zyzz/default'

export function AccountCard() {
  return (
    <section {...vars()}>
      <article {...styles.card()}>
        <h2 {...styles.title()}>Personal account</h2>
        <p {...styles.body()}>alex@example.com</p>
        <span {...styles.status()}>Active</span>
      </article>
    </section>
  )
}

namespace styles {
  export const body = style({ color: 'gray.900', typography: 'copy.14' })

  export const card = style({
    backgroundColor: 'background.surface',
    border: '1px solid',
    borderColor: 'gray.400',
    borderRadius: 'md',
    color: 'foreground',
    display: 'grid',
    gap: 2,
    justifyItems: 'start',
    // The md container size, 28rem
    maxWidth: 'md',
    // Spacing step 6, 1.5rem
    padding: 6,
  })

  export const status = style({
    backgroundColor: 'blue.100',
    borderRadius: '9999px !custom',
    color: 'blue.900',
    paddingBlock: 1,
    paddingInline: 3,
    typography: 'label.12',
  })

  // Sets font family, size, weight, letter spacing, and line height
  export const title = style({ typography: 'heading.20' })
}
```



Numeric spacing names such as `6` follow a quarter-rem scale, and `maxWidth: 'md'` resolves to the `md` container size. Typography sets combine font family, size, weight, letter spacing, and line height. The bundled config loads no fonts.

`vars` holds references that follow the active scope. The separate `tokens` export holds raw values for code outside CSS, such as a charting library. The [Default Theme](/docs/guides/default-theme) guide lists every group and set.

### Derive Semantic Tokens

A palette token names a raw value, such as `ink`. A semantic token names a purpose, such as `foreground`, and points at palette tokens. The second `defineVars` argument receives references to the first and returns the semantic tokens.

```ts title="zyzz.config.ts"
import { defineConfig, defineVars, extendVars } from 'zyzz'

const base = defineVars(
  // 1. Palette tokens name raw values
  { color: { palette: { ink: '#171717', paper: '#fafafa' } } },
  // 2. Semantic tokens point at palette tokens
  (vars) => ({
    color: {
      foreground: {
        dark: vars.color.palette.paper,
        light: vars.color.palette.ink,
      },
    },
  }),
)

// 3. Override a palette value, and foreground follows it
const alternate = extendVars(base, {
  color: { palette: { ink: '#2563eb' } },
})

export const { style, vars } = defineConfig({
  defaultVars: 'base',
  vars: { alternate, base },
})
```

In the alternate set, `foreground` turns blue in the light scheme and keeps the paper color in the dark scheme. The semantic token references the palette of whichever set is active, so a palette override needs no change to `foreground`.

The callback must be synchronous and return a literal object, because the compiler reads it without running it. It cannot reference other derived tokens or redefine a palette path. [defineVars](/docs/api/core/defineVars) covers references between separate sets and cycle validation.

### Adjust Color Opacity

`color-mix()` derives a translucent color from a token. `Vars.compose` turns the expression into a token of its own, which keeps a live reference, so it follows the accent of the active set and scheme.

```ts title="zyzz.config.ts"
import { defineConfig, defineVars, Vars } from 'zyzz'

const base = defineVars(
  { color: { accent: { dark: '#60a5fa', light: '#2563eb' } } },
  (vars) => ({
    color: {
      // A composed token keeps a live reference to accent
      accentSubtle: Vars.compose('color', [
        'color-mix(in srgb, ',
        vars.color.accent,
        ' 15%, transparent)',
      ]),
    },
  }),
)

export const { style, vars } = defineConfig({ vars: base })
```

```tsx title="Badge.tsx"
import { style } from './zyzz.config.js'

export function Badge() {
  return <span {...styles.badge()}>New</span>
}

namespace styles {
  export const badge = style({
    // Reads the composed token like any other color token
    backgroundColor: 'accentSubtle',
    color: 'accent',
  })
}
```

For a single declaration, interpolate the reference into the expression and add ` !custom`:

```ts
style({
  backgroundColor: `color-mix(in srgb, ${vars.color.accent} 15%, transparent) !custom`,
})
```

The browser evaluates the expression. React Native rejects composed values.

### Make Tokens Responsive

Give a token a `default` and ordered `@media` overrides. Each branch must hold a value from the same domain, and a color branch can hold a `{ dark, light }` pair.

```ts title="zyzz.config.ts"
import { defineConfig, defineVars } from 'zyzz'

const base = defineVars({
  spacing: {
    page: {
      // Used when no condition matches
      default: '1rem',
      // The last matching condition wins
      '@media (min-width: 48rem)': '2rem',
      '@media (min-width: 72rem)': '3rem',
    },
  },
})

export const { style, vars } = defineConfig({ vars: base })
```

`style({ padding: 'page' })` grows with the viewport. When several conditions match, the last authored one wins. `extendVars` replaces a responsive token as a whole, so an override lists every branch it keeps. Native builds select the matching branch from the window size, as described in [Responsive Tokens](/docs/guides/native/responsive#responsive-tokens).

### Name Query Thresholds

`breakpoint` and `container` tokens name the thresholds used in `@media` and `@container` keys. `containerNames` lists the container names that styles may declare and query.

```ts title="zyzz.config.ts"
import { defineConfig } from 'zyzz'

export const { style, vars } = defineConfig({
  vars: {
    // Named thresholds for @media keys
    breakpoint: { tablet: '48rem' },
    // Named thresholds for @container keys
    container: { card: '20rem' },
    // Container names that styles can declare and query
    containerNames: ['preview'],
  },
})
```

```tsx title="Preview.tsx"
import { style } from './zyzz.config.js'

export function Preview() {
  return (
    <section {...styles.region()}>
      <article {...styles.card()}>Account</article>
    </section>
  )
}

namespace styles {
  export const card = style({
    // Viewport is at least 48rem wide
    '@media >=tablet': { padding: '2rem' },
    // Nearest preview container is at least 20rem wide
    '@container preview >=card': { display: 'grid' },
  })

  // Declares the container that the card queries
  export const region = style({
    containerName: 'preview',
    containerType: 'inline-size',
  })
}
```

Thresholds compile to literal conditions, so selecting another set does not change them. [Conditions](/docs/guides/conditions) covers the full query syntax.

### Add Typography Presets

A `typography` token groups font properties under one name, with optional `@media` and `@container` blocks. The `typography` field applies the whole group.

```ts title="zyzz.config.ts"
import { defineConfig, defineVars } from 'zyzz'

const base = defineVars({
  breakpoint: { tablet: '48rem' },
  typography: {
    // A preset groups font properties under one name
    heading: {
      fontSize: '1.5rem',
      fontWeight: 600,
      lineHeight: 1.2,
      '@media >=tablet': { fontSize: '2.5rem' },
    },
  },
})

export const { style, vars } = defineConfig({ vars: base })
```

```tsx title="Title.tsx"
import { style } from './zyzz.config.js'

export function Title() {
  return <h1 {...styles.title()}>Account</h1>
}

namespace styles {
  // The explicit fontWeight overrides the preset's 600
  export const title = style({ fontWeight: 700, typography: 'heading' })
}
```

A field written beside `typography` overrides the matching preset field, regardless of position, so the title above uses weight `700`. Native compilation rejects responsive typography blocks.

### Customize Property Mappings

`mappings` connects a category to the properties that accept its tokens. Each supplied category replaces its default mapping, and other categories keep theirs. `shorthands` defines local aliases that expand into several properties.

```ts title="zyzz.config.ts"
import { defineConfig } from 'zyzz'

export const { style, vars } = defineConfig({
  // Properties that accept each category's short names
  mappings: {
    spacing: ['gap', 'padding', 'paddingLeft', 'paddingRight'],
    surface: ['backgroundColor'],
  },
  // A local alias that expands into several properties
  shorthands: { px: ['paddingLeft', 'paddingRight'] },
  vars: {
    spacing: { page: '1rem' },
    surface: { panel: { dark: '#171717', light: '#ffffff' } },
  },
})
```

```tsx title="Panel.tsx"
import { style, vars } from './zyzz.config.js'

export function Panel() {
  return <section {...styles.panel()}>Account details</section>
}

namespace styles {
  export const panel = style({
    // surface tokens apply to backgroundColor
    backgroundColor: 'panel',
    // Expands to paddingLeft and paddingRight
    px: 'page',
    // width is outside the spacing mapping, so it takes a reference
    width: vars.spacing.page,
  })
}
```

`width` needs an explicit reference, because this spacing mapping covers only gap and padding. Each property that a shorthand expands to checks the value on its own.

* **`[]`:** Disables short names for one category.
* **`mappings: false`:** Requires full paths such as `'surface.panel'` everywhere.
* **Dedicated categories:** A category such as `padding` takes precedence over `spacing` for the same name.

Avoid mappings that give one property the same name from two categories. [Config.create](/docs/api/core/defineConfig#optionsmappings) shows full-path authoring.

### Compile Without Bundlers

`Css.compile` turns explicit definitions into an in-memory stylesheet. The page loads the CSS and applies both the scope class and the component class.

```ts title="compile.ts"
import { defineVars, Style } from 'zyzz'
import { Css } from 'zyzz/web'

// 1. Define tokens and styles as data
const base = defineVars({
  color: { foreground: { dark: '#fafafa', light: '#171717' } },
})
const styles = Style.define({ card: { color: base.color.foreground } })

// 2. Compile them into a stylesheet and class names
const output = Css.compile({ styles, vars: { base } })

// 3. Apply the scope class and the component class
const markup = `<section class="${output.vars.base}"><article class="${output.classes.card}">Account</article></section>`
```

`output.css` holds the stylesheet, and `output.vars.base` and `output.classes.card` hold the class names. [Graph.compile](/docs/api/compiler/namespaces/Graph#graphcompile) covers multi-file source and library packaging.

## More

[Styling](/docs/guides/styling)

Define element styles, combine them in order, and bind changing values.

[Variants](/docs/guides/variants)

Define typed choices such as size and tone, with defaults and compounds.

[Variables](/vars)

Browse the bundled colors, spacing, typography, and effect token values.

[React Native](/docs/guides/native)

Apply themes and color schemes in native apps, and review the limits.

[Migrating from Tailwind](/docs/guides/tailwind)

Map Tailwind theme variables and dark mode onto Zyzz variable sets.

[Concepts & Principles](/docs/concepts)

See how tokens and scopes fit into the rest of the Zyzz styling model.
