Color & theming

Open Props palette ramps, project-level semantic aliases, and the 3-state (System / Light / Dark) theming recipe. The theme toggle demos at the bottom drive the actual /ui/lib/theme.mjs module — toggling either updates this page's theme immediately.

Palette — --{hue}-{0..12}

Each hue family has 13 steps from 0 (lightest) to 12 (darkest). Hues available: gray, stone, red, pink, purple, violet, indigo, blue, cyan, teal, green, lime, yellow, orange, choco, brown, sand, camo, jungle.

--gray-0 .. --gray-12

0
2
4
6
8
10
12

--indigo-0 .. --indigo-12

0
2
4
6
8
10
12

Scale guideline:

0–2
Page backgrounds, light surfaces
3–5
Borders, dividers, subtle fills
6–8
Accent fills, icon colors
9–12
Text, high-contrast elements

Alpha — color-mix

Compose translucent colors with color-mix in OKLCH. This project standardizes on color-mix for alpha (rather than Open Props' --{hue}-{n}-hsl variants) so color calculations use a perceptually uniform space. oklch is safe here because the other colour is transparent, which contributes no hue; a mix against an opaque near-neutral is written in oklab instead — see Radio group · color-mix.

Show code .tint { background: color-mix(in oklch, var(--brand) 10%, transparent); } .entry-logo { background: color-mix(in oklch, var(--brand) 15%, transparent); color: var(--brand); }

Semantic aliases

The project defines semantic color aliases for Open Props values in /index.css, so component CSS names each color by its purpose. Use the aliases, not the palette, when writing component CSS.

--surface-1
Page background
--surface-2
Card / section background
--surface-3
Raised elements, hover fills
--surface-overlay
Panels floating in the top layer — the x-select picker, the combobox list, ui-menu, ui-popover, ui-notifications. Not a step on the surface ramp: it equals --surface-1 in light and steps a tone lighter in dark. See Elevation § Overlay surfaces for why
--text-1
Primary text
--text-2
Muted text (captions, meta)
--brand
Primary action / accent
--on-brand
Text / icons sitting on a --brand fill (white in light mode; near-black in dark, where the brand is a light indigo)
--border-color
Subtle borders and dividers
--border-strong
Emphasised borders — hover states, focus-adjacent
--info / --success / --warning / --error
Feedback colors (text/icon roles — dark mode rebinds them to lighter palette steps) — see Notifications and Form field states

Two cases need more than a semantic alias:

Show code .card { background: var(--surface-2); color: var(--text-1); border: var(--border-size-1) solid var(--border-color); }

3-state theming (System / Light / Dark)

Swap alias values under three triggers, so the user can pick System, Light, or Dark:

Values are defined once as -light/-dark pairs (the open-props.style adaptive-colors pattern); the two dark-mode blocks only assign those paired values and must stay identical — a media query can't share a selector list with a class. The project's own tokens live in /index.css. Every page also includes <meta name="color-scheme" content="light dark"> so the browser uses the right color scheme before loading CSS.

Show code :root { color-scheme: light; /* value pairs — defined once */ --surface-1-light: var(--stone-0); --surface-1-dark: var(--stone-12); --text-1-light: var(--stone-11); --text-1-dark: var(--stone-1); /* A pair does NOT have to mirror the ramp. This one is flush with --surface-1 in light and a tone above it in dark, because a floating panel is separated by its shadow in light and by its fill in dark. */ --surface-overlay-light: var(--stone-0); --surface-overlay-dark: var(--stone-11); /* live bindings default to light */ --surface-1: var(--surface-1-light); --text-1: var(--text-1-light); --surface-overlay: var(--surface-overlay-light); } @media (prefers-color-scheme: dark) { /* --OSdark */ :root:not(.light) { color-scheme: dark; --surface-1: var(--surface-1-dark); --text-1: var(--text-1-dark); --surface-overlay: var(--surface-overlay-dark); } } :root.dark { color-scheme: dark; --surface-1: var(--surface-1-dark); --text-1: var(--text-1-dark); --surface-overlay: var(--surface-overlay-dark); }

Browser chrome — <meta name="theme-color">

Mobile browsers paint their address bar, and an installed app paints its title bar, from <meta name="theme-color">. Importing /ui/lib/theme.mjs keeps that tag updated with the current value of --surface-1 — creating the tag if needed — so the browser bars match the page through every theme change, including OS changes in System mode. The module handles this without additional page markup.

Why JavaScript updates the tag. The static form of this tag is one <meta> per media="(prefers-color-scheme: …)", which follows the OS preference. The switcher's Light and Dark choices override the OS preference. With static tags, choosing Light on a dark-mode OS would leave dark browser bars above a light page. Reading the computed token also keeps the color definition in one place, like the -light/-dark pairs above do.

Show code // What theme.mjs does on import, and again on every 'theme-change'. const meta = document.querySelector('meta[name="theme-color"]') ?? document.head.appendChild(Object.assign(document.createElement('meta'), {name: 'theme-color'})) meta.setAttribute('content', getComputedStyle(document.documentElement).getPropertyValue('--surface-1').trim())

A web app manifest carries a theme_color of its own, and it is a single static value — the format has no light/dark pair. Give it the light --surface-1, so the two agree wherever the runtime tag has not yet been applied (a splash screen, an install card), and let this tag correct it once the page runs.

theme.mjs — module API

The toggle demos below drive the same module the rest of the app uses. Source: /ui/lib/theme.mjs.

ExportSignatureDescription
get_theme () => 'system' | 'light' | 'dark' Read the user's current preference. Returns 'system' when no override is stored.
get_effective_theme () => 'light' | 'dark' Resolve 'system' against matchMedia('(prefers-color-scheme: dark)'). Returns the active light or dark theme.
set_theme (choice) => void Persist the choice to localStorage.theme, apply the class on <html>, set data-effective-theme, and dispatch 'theme-change'.
theme_changed_event string constant — 'theme-change' Event name. Use with document.addEventListener or theme_events.addEventListener.
theme_events EventTarget Module-level bus. Listen for 'theme-change' with detail { choice, effective }. Fires on user toggle and when the OS preference flips while in 'system' mode.

The same 'theme-change' event is also dispatched on document, for a listener in a component that never imports this module. Choose where to listen according to the listener's lifetime — see Conventions for the event-listener hierarchy.

Live readout — get_effective_theme + theme_events

This box updates whenever the choice changes or the OS theme flips while in 'system' mode. Toggle your OS appearance to see it in action.

get_theme() = …
get_effective_theme() = …

Show code

Flash prevention

A tiny inline <script> in <head> before the stylesheet links applies the saved choice before CSS parses, so a returning dark-mode user doesn't see a flash of light.

Show code <script> const t = localStorage.getItem('theme') const r = document.documentElement if (t === 'dark' || t === 'light') r.classList.add(t) r.dataset.effectiveTheme = t === 'dark' || t === 'light' ? t : matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' </script>

Demo A — sun-and-moon toggle (2-state)

Borrowed from Adam Argyle's web.dev article — an SVG sun with 8 beams, masked by a moving moon circle. All animation is CSS transforms (no JS animation). The button's aria-label reflects the current effective theme and aria-live="polite" announces updates to screen readers.

Reaching System with only two states: the click handler flips the effective theme, and when the result matches the OS preference it stores 'system' instead of the explicit value — so toggling onto whatever the OS already shows returns the user to following the OS. This provides access to all three preferences with one button. The app ships this packaged as <ui-theme-toggle> (/ui/components/ui-theme-toggle.mjs); Demo B below shows the explicit 3-button alternative.

Show HTML <button class="theme-toggle" type="button" aria-label="light" aria-live="polite" title="Toggle light & dark"> <svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24"> <mask class="moon" id="moon-mask"> <rect x="0" y="0" width="100%" height="100%" fill="white"/> <circle cx="24" cy="10" r="6" fill="black"/> </mask> <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor"/> <g class="sun-beams" stroke="currentColor" stroke-width="2" stroke-linecap="round"> <!-- 8 rays --> </g> </svg> </button>
Show CSS .theme-toggle { --size: 2rem; --icon-fill: var(--text-2); --icon-fill-hover: var(--text-1); background: none; border: none; padding: 0; inline-size: var(--size); block-size: var(--size); border-radius: var(--radius-round); cursor: pointer; } .theme-toggle > .sun-and-moon > :is(.moon, .sun) { fill: var(--icon-fill); } .theme-toggle > .sun-and-moon > .sun-beams { stroke: var(--icon-fill); } /* Effective-dark state: sun shrinks behind a mask, beams fade, moon slides in. */ :root[data-effective-theme="dark"] .sun-and-moon > .sun { transform: scale(1.75); } :root[data-effective-theme="dark"] .sun-and-moon > .sun-beams { opacity: 0; } :root[data-effective-theme="dark"] .sun-and-moon > .moon > circle { cx: 17; } @media (prefers-reduced-motion: no-preference) { .sun-and-moon > .sun { transition: transform .5s var(--ease-elastic-3); } .sun-and-moon > .sun-beams { transition: transform .5s var(--ease-elastic-4), opacity .5s var(--ease-3); } .sun-and-moon .moon > circle { transition: cx .25s var(--ease-out-5); } }

Demo B — segmented radiogroup (3-state)

The same 3-state model as a one-of-three choice — System / Light / Dark — built as the shared radio group: a <fieldset role="radiogroup"> of native radios. There is no roving tabindex and no keydown handler here, because the platform supplies the whole APG radio pattern for real radios. Each option's icon is aria-hidden="true"; the visible text is the accessible name.

This demo changes the appearance with two custom properties, --segmented-track and --segmented-selected: the picker uses --surface-2 for the track and --surface-1 for the selected option, replacing the default fill of --brand. See Radio group § Theming.

Show HTML
Show JS import { get_theme, set_theme, theme_changed_event } from '/ui/lib/theme.mjs' // Demo A — sun-and-moon toggle. Two visual states over the 3-value model: // landing on the OS's own value drops the override — back to 'system'. const sun_and_moon = document.getElementById('sun-and-moon') sun_and_moon.addEventListener('click', () => { const next = document.documentElement.dataset.effectiveTheme === 'dark' ? 'light' : 'dark' const os = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' set_theme(next === os ? 'system' : next) }) // Demo B — the shared radio group. One `change` listener is the whole // wiring: the roving tabindex, the arrow keys and aria-checked are the // browser's, because these are real radios. const picker = document.querySelector('#demo-theme-b .theme-picker') const radios = [...picker.querySelectorAll('input[type="radio"]')] picker.addEventListener('change', (e) => set_theme(e.target.value)) // Shared: re-render both controls on any theme change (including OS-level). function render() { const current = get_theme() const effective = document.documentElement.dataset.effectiveTheme sun_and_moon.setAttribute('aria-label', effective) for (const r of radios) r.checked = r.value === current } document.addEventListener(theme_changed_event, render) render()

Pitfalls

See also