---
id: "packages/active-ui/theming"
title: "Theming"
description: "A switchable light, dark or system theme with accents and fonts, the script that prevents a flash, and a theme derived from a brand colour."
url: "/packages/active-ui/theming"
locale: "en"
revision: "93a01135fbf2a264"
---

# Theming

active-ui's colours are CSS custom properties with a light and a dark value. A page with a fixed
scheme sets the `dark` class on `<html>` in its server render and needs nothing from this page. A
page where the reader switches the theme uses `ThemeProvider`, and `ThemeInitScript` so the first
frame has the right scheme. A product that lets an administrator choose a brand colour derives the
tokens with `deriveThemeTokens`.

## Deriving a theme from a brand colour

**Example: A theme derived from a brand colour**

Client: Runs entirely in your browser. Source: `examples/ui/theme-derivation.tsx`.

```tsx
// apps/docs/examples/ui/theme-derivation.tsx
'use client';

import {useMemo, useState} from 'react';

import {
  Field,
  FieldDescription,
  FieldLabel,
} from '@workspace/active-ui/components/primitives/field';
import {Input} from '@workspace/active-ui/components/primitives/input';
import {
  deriveThemeTokens,
  hasInsufficientBrandContrast,
} from '@workspace/active-ui/lib/theme-derivation';

const SWATCHES = [
  'background',
  'foreground',
  'primary',
  'primary-foreground',
  'muted',
  'border',
];

function Scheme({
  name,
  tokens,
}: {
  name: string;
  tokens: Readonly<Record<string, string>>;
}) {
  return (
    <div className="grid gap-2">
      <p className="text-sm font-medium">{name}</p>
      <ul className="grid grid-cols-3 gap-2 text-xs">
        {SWATCHES.map((token) => (
          <li key={token} className="grid gap-1">
            <span
              className="h-8 rounded-sm border"
              style={{background: tokens[token]}}
              aria-hidden
            />
            <code className="truncate">{token}</code>
          </li>
        ))}
      </ul>
    </div>
  );
}

/**
 * A theme derived from five seeds: change the brand colour to see both
 * schemes and the contrast check. The derivation is a pure function; nothing
 * on this page changes theme.
 */
export default function ThemeDerivationExample() {
  const [brandColor, setBrandColor] = useState('#1d4ed8');
  const valid = /^#[0-9a-f]{6}$/.test(brandColor);
  const theme = useMemo(
    () =>
      valid
        ? deriveThemeTokens({
            brandColor,
            neutral: {hue: 257, chromaScale: 1.8},
            radius: '0.65rem',
            bodyFontFamily: 'Geist, sans-serif',
            headingFontFamily: null,
          })
        : null,
    [brandColor, valid],
  );
  return (
    <div className="grid w-full max-w-lg gap-6">
      <Field data-invalid={!valid || undefined}>
        <FieldLabel htmlFor="brand-color">Brand colour</FieldLabel>
        <Input
          id="brand-color"
          value={brandColor}
          onChange={(event) => setBrandColor(event.target.value.toLowerCase())}
          aria-describedby="brand-color-check"
          aria-invalid={!valid || undefined}
        />
        <FieldDescription id="brand-color-check">
          {!theme
            ? 'Write a colour as #rrggbb.'
            : hasInsufficientBrandContrast(theme)
              ? `Below the contrast threshold: ${theme.contrast.light.primaryOnBackground.toFixed(2)}:1 on light, ${theme.contrast.dark.primaryOnBackground.toFixed(2)}:1 on dark.`
              : `Enough contrast: ${theme.contrast.light.primaryOnBackground.toFixed(2)}:1 on light, ${theme.contrast.dark.primaryOnBackground.toFixed(2)}:1 on dark.`}
        </FieldDescription>
      </Field>
      {theme ? (
        <div className="grid gap-6 sm:grid-cols-2">
          <Scheme name="Light" tokens={theme.light.tokens} />
          <Scheme name="Dark" tokens={theme.dark.tokens} />
        </div>
      ) : null}
    </div>
  );
}
```

`deriveThemeTokens(seeds)` turns five values into the tokens of both schemes: the brand colour
(`#rrggbb`), a neutral family (a hue and a chroma scale over the shared lightness ladder of
`lib/theme-registry`), the radius, and the body and heading fonts. It also measures the brand
colour on the background (threshold 3:1) and the text on the brand colour (4.5:1).
`hasInsufficientBrandContrast` turns that into a warning, never a refusal.
`themeStyleBlock(theme)` writes the one `<style>` block for the document head, scoped to
`html[data-website-theme]` so it wins over the defaults whatever the order of the stylesheets; it
interpolates only derived values.

The function is pure and runs in the browser too, so a settings screen can show the warning with the
same derivation the site will render.

## A switchable theme

```tsx
import {ThemeInitScript} from '@workspace/active-ui/providers/theme-init-script';
import {ThemeProvider} from '@workspace/active-ui/providers/theme-provider';

<html lang="en" suppressHydrationWarning>
  <head>
    <ThemeInitScript cookieName="theme-mode" serverMode={mode} />
  </head>
  <body>
    <ThemeProvider
      initialMode={mode}
      initialAccentColor="default"
      accentClasses={{default: null}}
      initialFont="sans"
      fonts={{sans: 'var(--font-geist-sans), sans-serif'}}
      fallbackFont="sans"
      persistence={{cookieNames, writeCookie}}
    >
      {children}
    </ThemeProvider>
  </body>
</html>;
```

- `ThemeInitScript` runs in the head before the page paints: it reads the mode from your cookie,
  falls back to `serverMode`, and for `system` to the device. A cookie value that is not `dark` or
  `light` is ignored.
- `ThemeProvider` holds the mode (`light`, `dark`, `system`), the accent and the font, and its own
  script applies the accent class and font before hydration. With `persistence` a change is written
  through your `writeCookie`; your application owns the cookie names and attributes.
  `cookieBackedPreferences` limits which preferences a cookie may set, for example on a sign-in
  screen.
- `useTheme()` reads and sets them, and gives `resolvedTheme`: `light` during the server render
  and hydration, then the device's scheme for `system`. Every field it returns is listed under
  `useTheme` in the API below.

This site has its own theme switch, so the provider is not run live on this page: the example
above shows theme derivation, not `ThemeProvider`. The provider, its hydration and the head script
are covered by the package's unit tests; a running host setup is not demonstrated here.

## What your application provides

- The accents and fonts it offers, as classes and `font-family` values, and the server-side
  resolution of the initial values.
- The cookies: names, domain, lifetime; and `suppressHydrationWarning` on `<html>`, whose class the
  script changes before React hydrates.
- The fonts themselves: active-ui ships no fonts.

## API

### ThemeProvider

**ThemeProvider**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `accentClasses` | `Readonly<Record<string, string \| null>>` | yes | The accents the host offers: key -> the class the accent sets on `<body>`, or `null` for an accent that sets none (the package's own tokens). The keys are the only values a cookie may carry; switching removes every class of this map and adds the chosen one. A host without accents passes `{default: null}`. |
| `children` | `ReactNode` | yes | The application. |
| `fallbackFont` | `string` | yes | The key whose family applies when a chosen key has none. |
| `fonts` | `Readonly<Record<string, string>>` | yes | Font key -> the complete `font-family` value for `--font-sans`.  This is the **family** and not a `var(--font-…)`. The host passes the mapping its server also uses for the inline style on `<html>` and the font preload, so both carry the same string by construction -- otherwise that is where a first-frame correction would arise. |
| `initialAccentColor` | `string` | yes | A key of {@link ThemeProviderProps.accentClasses}. |
| `initialFont` | `string` | yes | A key of {@link ThemeProviderProps.fonts}. |
| `initialMode` | `"dark" \| "light" \| "system"` | yes | The mode the server resolved: `light`, `dark` or `system`. |
| `cookieBackedPreferences` | `readonly CookieBackedPreference[]` | no | The preferences that may come from a cookie on *this* surface. Defaults to all three; anything not listed follows the server value.  For a surface where nobody is logged in, such as a sign-in screen: there is no person whose preference should apply. Preference cookies written at sign-in and left in place at sign-out would otherwise show the appearance of whoever signed in last, so two users on the same machine would see a different screen. That surface is site-wide and set by the administrator.  Such a surface declares, for example, `['mode']`: a light/dark toggle on that screen lets the visitor make that choice there themselves. A font or accent value could only arrive from elsewhere, so it does not apply. |
| `persistence` | `ThemePersistence` | no | The persistence of the choice. Without it the choice lasts for the page: the bootstrap script reads no cookie and a switch writes none. |

**useTheme**: `() => ThemeContextProps`

The current mode, accent and font, the scheme in effect, and their
setters. Throws outside a `ThemeProvider`.

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | `"dark" \| "light" \| "system"` | yes |  |
| `setMode` | `(mode: ThemeMode) => void` | yes |  |
| `resolvedTheme` | `"dark" \| "light"` | yes | The scheme in effect. For `system` it is the device preference; during the server render and the hydration render it is `light` (what the server could know), and it follows the device right after hydration, so markup that depends on it hydrates without a mismatch. |
| `accentColor` | `string` | yes | One of the keys of {@link ThemeProviderProps.accentClasses}. |
| `setAccentColor` | `(color: string) => void` | yes |  |
| `font` | `string` | yes | One of the keys of {@link ThemeProviderProps.fonts}. |
| `setFont` | `(font: string) => void` | yes |  |

**ThemeMode**

```ts
type ThemeMode = (typeof THEME_MODES)[number];
```

A colour scheme mode: `light`, `dark`, or `system`, which follows the
device preference and is a valid choice, not an absence. A host that
validates a stored mode checks it against these three values.

**CookieBackedPreference**

```ts
type CookieBackedPreference = 'mode' | 'accent' | 'font';
```

The three preferences this surface *can* read from a cookie.

**ThemePersistence**

```ts
interface ThemePersistence {
  cookieNames: {mode: string; accent: string; font: string};
  writeCookie: (name: string, value: string) => void;
}
```

Where a switchable theme keeps the visitor's choice. The host owns
the cookie names and the cookie contract (domain, lifetime, `Secure`): the
provider never builds a cookie string itself, it hands name and value to
`writeCookie`, so one writer of the host keeps the preference cookies.

**ThemeProviderProps**

```ts
interface ThemeProviderProps {
  children: ReactNode;
  initialMode: ThemeMode;
  initialAccentColor: string;
  accentClasses: Readonly<Record<string, string | null>>;
  initialFont: string;
  fonts: Readonly<Record<string, string>>;
  fallbackFont: string;
  persistence?: ThemePersistence;
  cookieBackedPreferences?: readonly CookieBackedPreference[];
}
```

### ThemeInitScript

**ThemeInitScript**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `serverMode` | `"dark" \| "light" \| "system"` | yes | The mode the server resolved, used when the cookie holds none. |
| `cookieName` | `string` | no | The cookie the mode may be read from.  Omitting it means: touch no storage and resolve only the system preference. A surface needs that when the server has already weighed and rejected the cookie, or when a stored preference must not override policy. Hiding a switch is not enforcement; this prop makes enforcement possible. |

### Theme derivation

**deriveThemeTokens**: `(seeds: ThemeSeeds) => DerivedTheme`

Derives the complete light and dark token sets from the seeds.

**hasInsufficientBrandContrast**: `(theme: DerivedTheme) => boolean`

Whether the derived brand colour falls below a threshold in *either* scheme. The result is a warning and never a refusal: the screen shows a
line next to the brand colour field and saving proceeds.

The function is pure and importable on the client, so the admin screen
computes the warning live with exactly the same derivation the site will
render. One derivation, two readers.

**THEME_SCOPE_ATTRIBUTE**: `"data-website-theme"`

The attribute that carries a website's token scope. It sits on `<html>`, so
one element carries both the scope and the mode class.

**themeStyleBlock**: `(theme: DerivedTheme) => string`

The one token block for the document head.

Four properties make it correct, and the first is the most important:

1. **Specificity instead of order.** `html[data-website-theme]` is (0,1,1)
and beats `:root` (0,1,0); `html[data-website-theme].dark` is (0,2,1) and
beats `.dark` (0,1,0). The result therefore does not depend on the order
in which the bundler injects the stylesheets. A block that relies on
cascade order silently breaks on a build change.
2. **The media branch covers the script-less case.** Even when the system
bootstrap script is blocked, the colours are right immediately; the
script then only adds the class.
3. **The class is still needed**, and that was measured: `globals.css`
declares `@custom-variant dark (&:is(.dark *))`, so every `dark:` utility
hangs off a `.dark` ancestor and not off the media query.
4. **Only the derived tokens are in it.** The semantic and the chart family
come unchanged from `themes.css`.

The output contains only validated and derived values: no user-supplied
string is ever interpolated into CSS.

**ThemeSeeds**

```ts
interface ThemeSeeds {
  readonly brandColor: string;
  readonly neutral: ThemeNeutralFamily;
  readonly radius: string;
  readonly bodyFontFamily: string;
  readonly headingFontFamily: string | null;
}
```

The input of the derivation: VALUES, never a host's keys.
A host keeps its own closed registers (which families, radii and fonts it
offers) and resolves its stored keys to these values before it derives.

**DerivedThemeScheme**

```ts
type DerivedThemeScheme = {
  readonly tokens: Readonly<Record<string, string>>;
  readonly backgroundHex: string;
};
```

**DerivedThemeContrast**

```ts
interface DerivedThemeContrast {
  readonly primaryOnBackground: number;
  readonly foregroundOnPrimary: number;
}
```

The measured ratios behind the contrast warning.

**DerivedTheme**

```ts
interface DerivedTheme {
  readonly base: Readonly<Record<string, string>>;
  readonly light: DerivedThemeScheme;
  readonly dark: DerivedThemeScheme;
  readonly contrast: {
    readonly light: DerivedThemeContrast;
    readonly dark: DerivedThemeContrast;
  };
}
```

### Neutral ladder

**NEUTRAL_TOKEN_NAMES**: `readonly ["background", "foreground", "card", "card-foreground", "popover", "popover-foreground", "secondary", "secondary-foreground", "muted", "muted-foreground", "accent", "accent-foreground", "border", "input", "sidebar", "sidebar-foreground", "sidebar-accent", "sidebar-accent-foreground", "sidebar-border"]`

The names of the surface and border tokens that follow from a neutral
family. The brand roles (`primary`, `ring`, the sidebar brand roles) are not
included: they come from the brand colour.

**neutralLadderStep**: `(family: ThemeNeutralFamily, scheme: "light" \| "dark", token: NeutralTokenName) => { l: number; c: number; h: number; whiteAlphaPercent?: number; }`

One step of a family's ladder: the OKLCH lightness, chroma and hue of a
neutral token in a scheme, and its white alpha for a translucent token.

**neutralExtremes**: `(family: ThemeNeutralFamily, scheme: "light" \| "dark") => { lightest: { l: number; c: number; h: number; }; darkest: { l: number; c: number; h: number; }; }`

The lightest and the darkest step of a family, per scheme. The derivation
picks the foreground on the brand colour from these: it is the
highest-contrast choice *within* the family and never a freely entered
colour.

**NeutralTokenName**

```ts
type NeutralTokenName = (typeof NEUTRAL_TOKEN_NAMES)[number];
```

**ThemeNeutralFamily**

```ts
interface ThemeNeutralFamily {
  readonly hue: number;
  readonly chromaScale: number;
}
```

A neutral family: a hue and a chroma scale over the shared ladder. Every
family keeps every lightness, so the contrast of each step is the measured
one (`theme-derivation.test.ts`).
