Theming
The runtime half of ranui's styling: switching between light, dark and system mode, persisting the choice, and overriding tokens on the fly.
The tokens themselves (what they are called and what each one is for) are the design system; the rules for choosing between them are the design guidelines. This page is only about applying them.
Use when you need light/dark theming in a ranui app: call
initThemeonce on load,setThemeto switch, andsetThemeToken(s)if you want to override individual tokens without shipping extra CSS.
There are exactly two colour themes: light and dark, plus a system mode that follows the
OS preference. The Apple-style Liquid Glass look is not a third colour theme — it is the
surface language: solid (default Graphite) or glass (frosted controls, tuned to the
macOS 27 Liquid Glass materials). Combine freely, e.g. setTheme('dark') + setSurface('glass').
Glass is never the library default — enable it on purpose.
(Earlier "theme pack" APIs were removed; setThemePack / RanThemePackName no longer exist.)
Quick start
import { initTheme, setTheme, getTheme, initSurface, setSurface, getSurface } from 'ranui/theme';
// Restore the persisted theme ('light' | 'dark' | 'system') from localStorage
initTheme();
// Restore liquid glass / solid surface (localStorage key `ran-surface`)
initSurface();
// Switch theme — persisted automatically
setTheme('dark');
setTheme('system'); // tracks prefers-color-scheme and updates live
getTheme(); // → 'light' | 'dark' | 'system' | ''
// Surface language — persisted on <html> under `ran-surface`
setSurface('glass');
getSurface(); // → 'glass'
setSurface('solid');The dedicated ranui/theme entry ships only the theming engine: importing it registers no
custom elements, so a page that just wants tokens and dark mode never pulls in the component
library. The same functions are also re-exported from the top-level ranui barrel.
setTheme writes a data-ran-theme (and a legacy theme) attribute onto <html>; all
component styles react to it. The choice is saved under the localStorage key ran-theme.
For ready-made switching UI, use two orthogonal selects: <r-theme-switch>
for system / light / dark, and <r-surface-switch> for Graphite / liquid glass. Theme never
forces a style, and glass still respects light and dark. Both stay in sync across instances;
the theme switch also updates theme-color metas. This documentation site puts both in the
header as one appearance pair.
API
| Function | Signature | Description |
|---|---|---|
initTheme |
(target?: ThemeTarget) => void |
Restore the saved theme from localStorage. Call once on load. No-op in SSR. |
setTheme |
(name: RanThemeName, target?: ThemeTarget) => void |
Apply 'light' | 'dark' | 'system' and persist it. 'system' tracks the OS live. |
getTheme |
(target?: ThemeTarget) => RanThemeName | '' |
Read the active theme. Returns 'system' when system mode is active, '' if none is set. |
setSurface |
(name: RanSurfaceName, target?: ThemeTarget) => void |
Apply 'solid' | 'glass'. solid removes data-ran-surface. Persists when target is <html>. |
getSurface |
(target?: ThemeTarget) => RanSurfaceName |
Read the surface language; missing attribute means 'solid'. |
initSurface |
(target?: ThemeTarget) => void |
Restore the saved surface from localStorage. Call once on load. No-op in SSR. |
setThemeToken |
(name: string, value: string | number, target?: HTMLElement) => void |
Override a single token at runtime (inline style on the target). |
setThemeTokens |
(tokens: ThemeTokenMap, target?: HTMLElement) => void |
Override many tokens at once. A null / undefined value clears that token. |
clearThemeToken |
(name: string, target?: HTMLElement) => void |
Remove a runtime token override. |
Types
type RanThemeName = 'light' | 'dark' | 'system';
type RanSurfaceName = 'solid' | 'glass';
type ThemeTarget = HTMLElement | Document; // defaults to document.documentElement
type ThemeTokenMap = Record<string, string | number | null | undefined>;target: every function defaults to <html> (document.documentElement). Pass an element
to scope a theme or token override to a subtree instead of the whole page.
SSR-safe: all document / localStorage / matchMedia access is guarded, so these
functions are inert (not throwing) during server rendering.
Surface language (solid / glass)
This is how you ship a glass theme without adding another palette. Colour theme and surface language are independent axes — see the surface language guide (Apple Liquid Glass: functional layer vs content layer).
setSurface('glass') writes data-ran-surface="glass" on the target (usually <html>).
| Under document glass | Behaviour |
|---|---|
Default r-button |
Frosts (Apple glass) |
type="primary" / warning |
Stay solid (Apple glassProminent) |
r-modal, non-toast r-message |
Overlay frost |
r-card, r-input |
Stay solid (content layer); opt in with surface="glass" |
| Toast pills | Stay solid |
Set surface="solid" on a control to opt out of document glass. prefers-reduced-transparency: reduce falls back to elevated solid tokens, matching r-glass.
For a full liquid pane (lens refraction, materials, rim), keep using r-glass
— the surface axis is the control-family frost, not a replacement for that component.
How dark mode works
setTheme('dark') sets data-ran-theme="dark" on <html>. The stylesheet then redefines
only the base palette for dark, from a single source of truth; every --ran-color-*
semantic token references that palette through var(), so it flips automatically and no
component carries a dark-mode override of its own.
Two consequences worth knowing:
- Your own CSS gets dark mode for free if it consumes semantic tokens, and gets it wrong if it hard-codes a color or writes a light-only fallback. See Using tokens in your own CSS.
- Nothing should transition on a theme flip. CSS cannot tell why a color changed, so a
transitionon a palette property fades every element at its own pace when the theme switches. ranui's components deliberately don't; yours shouldn't either.
Customizing tokens
At runtime (JS)
import { setThemeToken, setThemeTokens, clearThemeToken } from 'ranui/theme';
// One token, on <html> (affects everything)
setThemeToken('--ran-color-primary', '#7c3aed');
// Many at once
setThemeTokens({
'--ran-color-primary': '#7c3aed',
'--ran-radius-md': '8px',
});
// Scope to a subtree
setThemeToken('--ran-color-primary', '#e11d48', document.querySelector('#panel'));
// Remove an override
clearThemeToken('--ran-color-primary');At build time (CSS)
Override tokens under :root, or any scope you like:
:root {
--ran-color-primary: #7c3aed;
--ran-radius-md: 8px;
}Which layer to override
Because dark mode redefines only the base palette:
- Override a semantic token (
--ran-color-primary) for a change that should be the same in both themes. - Override a base scale step (
--ran-blue-700) when the change should flip with the theme: everything semantic that references it follows. - Override a component token (
--ran-btn-hover-background) to change exactly one element. - Override a skin primitive (
--ran-skin-font-family,--ran-skin-raised-shadow, border width/style) when a host brand kit should restyle chrome without inventing a second token system. Keep Graphite/--ran-*as the product surface; loadranui/fontsif the stack still names Geist, or point--ran-skin-font-familyat the host face.
That layering is described in full on the design system page. Note that a runtime override is an inline style on the target: it wins over stylesheet rules for that subtree, which is what makes per-panel theming work, and what makes an override you forgot to clear hard to spot later.
Scoping a theme to part of the page
Every function takes a target, so a preview pane can run a different theme from the page around it:
const preview = document.querySelector('#preview');
setTheme('dark', preview); // this subtree only
getTheme(preview); // → 'dark'The attribute lands on that element instead of <html>, and the token cascade does the rest.