Theming
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.
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 Theme, and Theme so the first
frame has the right scheme. A product that lets an administrator choose a brand colour derives the
tokens with derive.
Deriving a theme from a brand colour#
Enough contrast: 6.09:1 on light, 3.03:1 on dark.
Light
backgroundforegroundprimaryprimary-foregroundmutedborder
Dark
backgroundforegroundprimaryprimary-foregroundmutedborder
// 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> );}derive 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/), 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).
has turns that into a warning, never a refusal.
theme writes the one <style> block for the document head, scoped to
html[data- 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#
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>;Themeruns in the head before the page paints: it reads the mode from your cookie, falls back toInit Script server, and forMode systemto the device. A cookie value that is notdarkorlightis ignored.Themeholds the mode (Provider light,dark,system), the accent and the font, and its own script applies the accent class and font before hydration. Withpersistencea change is written through yourwrite; your application owns the cookie names and attributes.Cookie cookielimits which preferences a cookie may set, for example on a sign-in screen.Backed Preferences usereads and sets them, and givesTheme() resolved:Theme lightduring the server render and hydration, then the device's scheme forsystem. Every field it returns is listed underusein the API below.Theme
This site has its own theme switch, so the provider is not run live on this page: the example
above shows theme derivation, not Theme. 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-values, and the server-side resolution of the initial values.family The cookies: names, domain, lifetime; and
suppressonHydration Warning <html>, whose class the script changes before React hydrates.The fonts themselves: active-ui ships no fonts.
API#
ThemeProvider#
ThemeProvider
Props
- Type
Readonly<โฆ>
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}.
- Type
ReactNode
The application.
- Type
string
The key whose family applies when a chosen key has none.
- Type
Readonly<Record<string, string>>
Font key -> the complete font- value for --font-.
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.
- Type
string
A key of Theme.
- Type
string
A key of Theme.
- Type
"dark""light""system"
The mode the server resolved: light, dark or system.
- Type
ThemePersistence
The persistence of the choice. Without it the choice lasts for the page: the bootstrap script reads no cookie and a switch writes none.
Takes only the props listed.
Functions, hooks and constants
- Type
() => ThemeContext Props
The current mode, accent and font, the scheme in effect, and their setters. Throws outside a Theme.
Returns
An object with these fields.
- Type
"dark""light""system"
- Type
(mode: ThemeMode) => void
- Type
"dark""light"
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.
- Type
string
One of the keys of Theme.
- Type
(color: string) => void
- Type
string
One of the keys of Theme.
- Type
(font: string) => void
Types
- Type
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.
Resolves to "dark" | "light" | "system".
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 write, so one writer of the host keeps the preference cookies.
interface ThemePersistence {
cookieNames: {mode: string; accent: string; font: string};
writeCookie: (name: string, value: string) => void;
}The props of Theme, each described in its prop list on this page.
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
Props
- Type
"dark""light""system"
The mode the server resolved, used when the cookie holds none.
Takes only the props listed.
Theme derivation#
Functions, hooks and constants
- Type
(seeds: ThemeSeeds) => Derived Theme
Derives the complete light and dark token sets from the seeds.
- Type
(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.
- Type
"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.
- Type
(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- is (0,1,1) and beats :root (0,1,0); html[data- is (0,2,1) and beats . (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. declares @custom-, so every dark: utility hangs off a . 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..
The output contains only validated and derived values: no user-supplied string is ever interpolated into CSS.
Types
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.
interface ThemeSeeds {
readonly brandColor: string;
readonly neutral: ThemeNeutralFamily;
readonly radius: string;
readonly bodyFontFamily: string;
readonly headingFontFamily: string | null;
}type DerivedThemeScheme = {
readonly tokens: Readonly<Record<string, string>>;
readonly backgroundHex: string;
};The measured ratios behind the contrast warning.
interface DerivedThemeContrast {
readonly primaryOnBackground: number;
readonly foregroundOnPrimary: number;
}interface DerivedTheme {
readonly base: Readonly<Record<string, string>>;
readonly light: DerivedThemeScheme;
readonly dark: DerivedThemeScheme;
readonly contrast: {
readonly light: DerivedThemeContrast;
readonly dark: DerivedThemeContrast;
};
}Neutral ladder#
Functions, hooks and constants
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.
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"]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.
(family: ThemeNeutralFamily, scheme: "light" | "dark", token: NeutralTokenName) => { l: number; c: number; h: number; whiteAlphaPercent?: 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.
(family: ThemeNeutralFamily, scheme: "light" | "dark") => { lightest: { l: number; c: number; h: number; }; darkest: { l: number; c: number; h: number; }; }Types
Resolves to "accent" | "accent-.
type NeutralTokenName = (typeof NEUTRAL_TOKEN_NAMES)[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-).
interface ThemeNeutralFamily {
readonly hue: number;
readonly chromaScale: number;
}