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.
--{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
--indigo-0 .. --indigo-12
Scale guideline:
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.
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--surface-2--surface-3--surface-overlayx-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--text-2--brand--on-brand--brand fill (white in light mode; near-black in dark, where the brand is a light indigo)--border-color--border-strong--info / --success / --warning / --errorTwo cases need more than a semantic alias:
color-mix(in oklab, <ink> 60%, var(--text-1))
deepens an accent toward black on light surfaces and lifts it toward white on dark ones,
because --text-1 itself flips. Used for callout text and dark status colors.
oklab, not oklch: --text-1 is
near-neutral rather than neutral, so its residual hue affects the mix. The two browser engines produce different hues — orange in Chromium against magenta in
Firefox for this combination. See
Radio group · color-mix.Swap alias values under three triggers, so the user can pick System, Light, or Dark:
:root — follow the OS via prefers-color-scheme.:root.dark — force dark even on a light-mode OS.:root.light — force light even on a dark-mode OS.
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.
<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.
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.
| Export | Signature | Description |
|---|---|---|
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.
get_effective_theme + theme_eventsThis 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() = …
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.
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.
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.
--surface-1, not --stone-0) so components adapt to light/dark automatically.color-mix(in oklch, …) — not RGB hex + opacity.prefers-reduced-motion./ui/lib/theme.mjs — theme state + theme-change event.theme-change is dispatched at tiers 2 and 3).