---
title: Theming
url: "https://native-sdk.dev/docs/theming"
docs_index: /llms.txt
lastUpdated: 2026-10-16
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

Built-in components use `DesignTokens` for their default colors, typography, dimensions, and interaction styles. Select a built-in theme pack, apply sparse token overrides, or provide a complete token set. Zig views can also set local `ElementOptions.style` overrides; these take precedence over token references.

## Theme packs

A theme pack supplies a complete set of design tokens. Two packs are available:

- `house` (the default) — the monochrome neutral token set documented throughout these pages.
- `geist` — the design token set of the bundled Geist typeface family: a cool neutral scale over pure white/black pages, monochrome primaries, blue focus rings, 6px control corners, and a taller 32/40/48 control ladder.

Select a pack in `app.json`:

```json
{
  "id": "com.example.app",
  "name": "example",
  "version": "0.1.0",
  "theme": "geist"
}
```

An unknown name is a build error listing the valid packs — never a silent fallback. The scaffold passes the manifest's choice into the app through `app_runner.manifestThemePack()`; wire it to your `UiApp` options and the pack composes with the live system appearance, so a packed app still re-themes on the OS light/dark flip (plus high contrast and reduced motion), no restart:

```zig
const MyApp = native_sdk.UiApp(Model, Msg);
// In your options:
.theme = app_runner.manifestThemePack(),
```

In a zero-config TypeScript app, export `themeState(model)` when pack, color scheme, or accent belongs in live app state. The generated launcher recognizes the helper and wires the stock-token path automatically:

```ts
import { type ThemeState } from "@native-sdk/core/events";

export type ThemePack = "house" | "geist";
export type ThemePreference = "system" | "light" | "dark";

export interface Model {
  readonly theme: ThemePack;
  readonly themePreference: ThemePreference;
  readonly pinkAccent: boolean;
}

export function themeState(model: Model): ThemeState {
  if (model.pinkAccent) {
    return {
      pack: model.theme,
      colorScheme: model.themePreference,
      accent: "#df2670",
    };
  }
  return { pack: model.theme, colorScheme: model.themePreference };
}
```

`ThemeState` has five optional fields:

```ts
export type ThemeState = {
  readonly pack?: "house" | "geist";
  readonly colorScheme?: "light" | "dark" | "system";
  readonly accent?: string; // exactly #rrggbb
  readonly highContrast?: boolean;
  readonly reduceMotion?: boolean;
};
```

Change the model through ordinary messages. The helper runs after every committed update. Omitted fields inherit the next lower layer; omitted `colorScheme` and `"system"` both follow the OS. An invalid accent produces a runtime error. Omitted `highContrast` and `reduceMotion` follow the OS. Explicit values, including `false`, retain model-owned appearance during replay. Effective high contrast suppresses manifest and model accent overrides.

The stock-theme precedence is:

<table>
  <thead>
    <tr>
      <th>
        Axis
      </th>

      <th>
        Highest to lowest precedence
      </th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>
        Complete tokens
      </td>

      <td>
        `tokens_fn`

        → static

        `tokens`

        → stock theme composition below
      </td>
    </tr>

    <tr>
      <td>
        Pack
      </td>

      <td>
        `themeState.pack`

        →

        `manifest theme`

        →

        `house`
      </td>
    </tr>

    <tr>
      <td>
        Color scheme
      </td>

      <td>
        forced

        `themeState.colorScheme`

        → OS appearance
      </td>
    </tr>

    <tr>
      <td>
        Accent
      </td>

      <td>
        `themeState.accent`

        →

        `manifest theme_accent`

        → selected pack
      </td>
    </tr>

    <tr>
      <td>
        High contrast / reduced motion
      </td>

      <td>
        explicit

        `themeState.highContrast`

        /

        `themeState.reduceMotion`

        → OS appearance (effective high contrast suppresses accent overrides)
      </td>
    </tr>

    <tr>
      <td>
        Surface scale / text measurement
      </td>

      <td>
        Runtime-stamped last
      </td>
    </tr>
  </tbody>
</table>

`themeState` deliberately controls canvas design tokens in v1. Native title bars and WebViews still follow the platform's effective appearance; forcing dark canvas content does not call `NSApp.appearance` or impose a scheme on embedded web content.

The earlier `themePack(model): "house" | "geist"` helper remains supported unchanged for apps that only switch packs. It preserves live OS scheme, manifest accent, high contrast, reduced motion, and surface scale. Export `themePack` or `themeState`, never both; the checker and adapter report that conflict as an error.

Apps that derive their own tokens select the pack directly — `ThemeOptions.pack` is just another theme axis, exactly as switchable at runtime as the scheme:

```zig
pub fn myTokens(model: *const Model) canvas.DesignTokens {
    return canvas.DesignTokens.theme(.{
        .color_scheme = model.system_scheme,
        .pack = if (model.brand_preview) .geist else .house,
    });
}
```

Pack selection is deliberately manifest/API vocabulary, not markup: a document describes structure, the app owns its look.

## Pin your design system in CI

To detect rendering changes, render a reference screen in a test and pin its pixel signature. Use the same renderer, fonts, scale, and environment for each run. The SDK uses reference signatures for both built-in packs. Review light and dark captures before updating a signature; the test covers the rendered screen, rather than every possible component state.

## The overrides layer

`DesignTokenOverrides` applies a sparse set of optional fields over a base theme. Null fields keep the base value:

```zig
var tokens = canvas.DesignTokens.themeWithOverrides(.{
    .color_scheme = scheme,
    .pack = .geist,
}, .{
    // Brand accent over the pack's monochrome primary.
    .colors = .{ .accent = canvas.Color.rgb8(0, 120, 111), .accent_text = canvas.Color.rgb8(240, 253, 250) },
    // Sharper corners across the token set.
    .radius = .{ .sm = 4, .md = 4, .lg = 4 },
});
```

The layering order is always: pack token set → your overrides → runtime-stamped values (the surface scale in `pixel_snap.scale`, platform text measurement). Overrides exist for every token group, including the control tables (`controls.button_primary.background = …`), the interaction-state formulas (`states`), and the metric ladder (`metrics`).

## The token groups

The token groups are:

- `colors` — the semantic palette: page/surface/washes, text inks, border hairline, the accent pair, the four semantic hues (destructive/success/warning/info) with their knockout inks, focus ring, shadow ink, scrim, disabled fill.
- `controls` — per-control visual tables (`button_primary`, `input`, `card`, …). Each entry states background/hover/active/pressed/disabled colors, foreground, border, radius, and stroke width; null fields fall through to the semantic palette and the state formulas. This is where a design system's per-control exceptions live — a filled error button, a 12px card corner — without touching any renderer.
- `states` — the interaction formulas applied when a control table is silent: the filled hover/pressed alpha cuts, the disabled wash strength, the destructive chip's wash ladder, the static-text selection wash.
- `metrics` — the control size token set: the sm/default/lg height ladder, per-rung button insets, button label steps, icon extents and gaps, row extent.
- `typography` — faces and the type rungs (body/label/title/button/heading/display). The face slots (`font_id`/`mono_font_id`) take app-registered faces too — see [Fonts](/docs/fonts).
- `radius`, `stroke`, `spacing` — the corner scale, hairline/focus stroke widths plus the focus ring's offset gap, and the spacing scale.
- `shadow`, `blur`, `motion`, `scroll`, `layer`, `pixel_snap` — elevation, backdrop blur, motion durations/easing, scroll physics, z-bands, and snapping.

## Authoring a full design system

To provide a complete design system, return a `DesignTokens` value for each supported color scheme and contrast setting:

1. Use your system's published values: the neutral scale, the accent, the semantic hues, the type rungs, the radii, the control heights.
2. Fill `colors` for all four scheme/contrast pairs (light, dark, and both high-contrast variants — high contrast is not optional; push inks to their extremes and borders toward opaque).
3. Use `controls` for per-control exceptions. Prefer the shared `colors` palette for values used across controls.
4. Restate `states` and `metrics` only if your system's interaction feedback or size ladder differs from the defaults.
5. Pin a reference signature (see above) and review light and dark captures by eye before blessing it.

```zig
pub fn brandTokens(scheme: canvas.ColorScheme, contrast: canvas.ColorContrast) canvas.DesignTokens {
    return .{
        .colors = brandColors(scheme, contrast),
        .controls = brandControls(scheme, contrast),
        .typography = .{ .body_size = 14, .title_size = 20 },
        .radius = .{ .sm = 4, .md = 6, .lg = 8, .xl = 12 },
        .metrics = .{ .control_height = 36 },
    };
}
```

Hand it to your app via `tokens_fn` (model-owned, follows the system scheme through your model) or `tokens` (fixed). These complete-token paths take precedence over `themeState`; the runtime stamps `pixel_snap.scale` and text measurement after your function runs, so never cache those.

## What themes cannot do

Tokens change visual defaults. They do not change control behavior, states, semantics, or layout structure. Use component composition when you need a different structure.

## Theme, eject, or build

Choose the customization path according to the change you need:

- **Theme it.** Engine controls — buttons, text fields, tabs, every widget whose behavior lives in the runtime — are themed, never forked. The depth above ([packs](#theme-packs), [overrides](#the-overrides-layer), [full design systems](#authoring-a-full-design-system)) sets their visual defaults, with per-control overrides through the `controls` tables.
- **Eject it.** Library composites — views the SDK itself builds out of primitives — can become your code when you need to own their shape: `native eject component <name>` writes the canonical source into `src/components/`, building the identical widget tree at the moment of ejection. See [Building Components](/docs/building-components#use-eject-or-build) for the ejectable set and the mechanics.
- **Build it.** When neither fits, compose a new component from the same primitives the library uses — [Building Components](/docs/building-components) walks through it. Use the shared tokens in your component so it follows the app theme.

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)