Theming#

A theme is a named block of token values in a design system's compiled CSS, selected by data-theme on any ancestor. Zero's theme engine holds metadata about themes — names, colour schemes, light/dark pairings, swatches — and a small controller that manages the user's explicit choice. Token values never pass through JavaScript.

Scheme and theme are different axes#

The colour scheme is the closed CSS pair 'light' | 'dark': it maps onto color-scheme and prefers-color-scheme, which have exactly those values. Theme names are open — dim is a theme whose scheme is dark, not a third scheme. Selection is three-valued: an explicit theme name, or null = follow the system.

The system default needs no JavaScript. A compiled design system writes its colour tokens on :root with light-dark() and color-scheme: light dark, so with no data-theme set the page follows the OS preference — defaultLight under a light system, defaultDark under a dark one. The controller only manages explicit choices.

Registering a design system's themes#

Every design system exports installThemes(), which hands its whole token declaration to the registry:

TypeScript
import { installThemes } from '@sigx/zero-daisyui';
installThemes();   // light / dark (paired), dim, nord, sunset

Inside, that is one call — registerThemes(tokens) — and the registry derives the rest:

TypeScript
import { registerThemes } from '@sigx/zero';
import { tokens } from './tokens.js';

export function installThemes(): void {
    registerThemes(tokens);
}

ThemeSource is typed structurally on purpose: the kit's TokensInput is assignable to it, so a design system passes tokens straight through while @sigx/zero never imports the Node-only kit. The registry reads only the keys it needs: themes (name → colorScheme, pair, colors), defaultLight / defaultDark, and roles / swatch for the swatch.

The swatch — the handful of colours a theme picker samples — is derived from the design system's own declaration: tokens.swatch if it declares one, otherwise the first four declared roles plus base-100 and base-content (defaultSwatch(roleNames), the same rule the compiler applies when writing the manifest). A design system whose distinguishing colours are its surfaces rather than primary declares a swatch that says so.

registerTheme(info) registers one theme by hand; clearThemes() empties the registry (a runtime design-system swap calls it before installing the next); getTheme(name), listThemes(), pairOf(name) and pickThemeFor(scheme) read it. pickThemeFor prefers the source's declared defaultLight / defaultDark over the first theme registered with that scheme.

The controller#

TypeScript
import { themeController, useTheme } from '@sigx/zero';

const theme = useTheme();          // inside a component — the injected controller
theme.theme();                     // 'dim' | … | null (null = following the system)
theme.resolvedScheme();            // 'light' | 'dark' — the effective scheme
theme.setTheme('nord');            // explicit choice, persisted
theme.setTheme(null);              // follow the system again
theme.toggle();                    // the current theme's pair, else the other scheme's default

themeController() is a lazily created browser singleton (it throws under SSR). It reads the persisted choice from localStorage (key zero-theme, DEFAULT_STORAGE_KEY), stamps data-theme on <html> whenever the explicit theme changes, and follows the OS preference through matchMedia while theme() is null. useTheme is the injectable that resolves to it by default.

Name typing follows one rule: authoring is closed, anything that round-trips through storage or the registry is open. setTheme takes ZeroThemeName — narrowed to the design system's real theme names by its /register module, so setTheme('dimm') is a compile error — while theme()'s return, getTheme and registerTheme stay open, because a persisted name may come from an older app version or a runtime-registered tenant theme.

ThemeProvider and ThemeScope#

TSX
import { ThemeProvider, ThemeScope } from '@sigx/zero';

<ThemeProvider theme={requestTheme} storageKey="acme-theme">
    <App />
</ThemeProvider>

<ThemeScope theme="dark">
    <Card.Root>…</Card.Root>      {/* an island that uses the dark theme's tokens */}
</ThemeScope>

ThemeProvider creates a separate controller for its subtree (createThemeController) and provides it as useTheme — the per-request answer on the server, where the singleton cannot exist, seeded with initial from a cookie. ThemeScope is just a data-theme island: everything inside uses that theme's tokens, because theme blocks are emitted at [data-theme="…"] and inherit down.

Every client controller writes the same document.documentElement attribute while holding its own signal state, and nothing synchronises the signals. Only the singleton listens for clearThemes(). If an app swaps design systems at runtime, capture the current theme before clearThemes() and re-apply it after installThemes() when the incoming system defines the same name.

First paint#

An explicit choice persisted in localStorage has to apply before the first frame, or an SSR/SSG page flashes the default theme. themeInitScript() returns a tiny inline IIFE for <head> that reads the key and stamps data-theme:

TSX
import { themeInitScript } from '@sigx/zero';

<script innerHTML={themeInitScript({ storageKey: 'acme-theme' })} />

The system preference needs no script — that is light-dark()'s job.

Multiple themes in one design system#

A design system declares any number of themes. Each theme block in the compiled CSS is diff-only — only the tokens that diverge from :root — with two deliberate exceptions restated per theme: scheme-divergent non-colour values (so a data-theme="light" island under a system-dark root does not inherit the dark shadow), and tokens whose values reference colour properties (a var() in a custom property substitutes where it is declared, so a shadow that reads --color-primary must be restated where --color-primary changes). @sigx/zero-daisyui's five themes — light and dark as a pair, plus dim (dark), nord (light) and sunset (dark) — are the shipped exercise; under its /register import the theme union is exactly those five.

Non-colour tokens and dark#

light-dark() is a <color> function. A non-colour token that differs between the default light and dark themes — a heavier shadow, a thicker border — is emitted in a prefers-color-scheme: dark block instead, from the design system's systemDark declaration. See Tokens.