Skip to content

Theming

ranui ships a light/dark theme system built on design tokens (CSS custom properties). Components never hard-code colors — they read semantic tokens, so switching theme or overriding a token restyles the whole library at once. The token system is based on the Geist design language.

There are exactly two themes — light and dark — plus a system mode that follows the OS preference. (Earlier "theme pack" APIs were removed; setThemePack / RanThemePackName no longer exist.)

Quick start

Call initTheme() once on page load to restore the user's saved choice, then setTheme() to switch:

js
import { initTheme, setTheme, getTheme } from 'ranui';

// Restore the persisted theme ('light' | 'dark' | 'system') from localStorage
initTheme();

// Switch theme — persisted automatically
setTheme('dark');
setTheme('system'); // tracks prefers-color-scheme and updates live

getTheme(); // → 'light' | 'dark' | 'system' | ''

setTheme writes 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.

API

FunctionSignatureDescription
initTheme(target?: ThemeTarget) => voidRestore the saved theme from localStorage. Call once on load. No-op in SSR.
setTheme(name: RanThemeName, target?: ThemeTarget) => voidApply '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.
setThemeToken(name: string, value: string | number, target?: HTMLElement) => voidOverride a single token at runtime (inline style on the target).
setThemeTokens(tokens: ThemeTokenMap, target?: HTMLElement) => voidOverride many tokens at once. A null / undefined value clears that token.
clearThemeToken(name: string, target?: HTMLElement) => voidRemove a runtime token override.

Types

ts
type RanThemeName = 'light' | 'dark' | 'system';
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.

Token layers

Tokens come in two layers. Only consume the semantic layer in your app — it flips automatically between light and dark.

Layer 1 — base palette (raw scales, rarely used directly): each color runs 100 → 1000 in 10 steps — --ran-gray-100..1000, --ran-gray-alpha-100..1000, --ran-blue/red/amber/green-100..1000, plus --ran-background-100/200.

Layer 2 — semantic tokens (--ran-color-* and friends) map onto the base scale. Dark mode redefines only the base scale, so every semantic token flips through var() with no per-component dark overrides.

Semantic color tokens

TokenRole
--ran-color-primaryPrimary action
--ran-color-primary-hoverPrimary hover
--ran-color-primary-activePrimary active
--ran-color-successSuccess
--ran-color-warningWarning
--ran-color-dangerDanger / error
--ran-color-bgPage background
--ran-color-bg-subtleSubtle background
--ran-color-bg-elevatedCard / surface background
--ran-color-bg-mutedMuted surface
--ran-color-bg-hoverHover surface
--ran-color-bg-activeActive surface
--ran-color-textPrimary text
--ran-color-text-secondarySecondary text
--ran-color-text-disabledDisabled text
--ran-color-borderDefault border
--ran-color-border-secondarySubtle border
--ran-color-border-hoverHover border
--ran-color-border-activeActive border
--ran-color-linkLink color

Color is a state ladder, not a palette. Within a scale, each step has a fixed job: 100 default bg · 200 hover bg · 300 active bg · 400 border · 500 hover border · 600 active border · 700 solid · 800 solid hover · 900 secondary text · 1000 primary text.

Non-color tokens

GroupTokens
Radius--ran-radius-sm 6px · --ran-radius-md 12px · --ran-radius-lg 16px · --ran-radius-full
Spacing--ran-space-1..24 (4px base: 4 · 8 · 12 · 16 · 24 · 32 · 40 · 64 · 96)
Elevation--ran-shadow-elevated (in-flow surface) · --ran-shadow-menu (overlay) · --ran-shadow-modal (dialog)
Z-index--ran-z-modal 1000 · --ran-z-dropdown 1100 · --ran-z-message 1200
Motion--ran-motion-duration-fast 0.15s · --ran-motion-duration-base 0.2s
Focus--ran-focus-ring
Typography--ran-font-family (Geist Sans) · --ran-font-mono (Geist Mono)

Customizing tokens

At runtime (JS)

js
import { setThemeToken, setThemeTokens, clearThemeToken } from 'ranui';

// 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 the semantic tokens under :root (or any scope). Because dark mode only redefines the base scale, override semantic tokens for theme-agnostic changes, or the base scale if you want the change to flip too:

css
:root {
  --ran-color-primary: #7c3aed;
  --ran-radius-md: 8px;
}

How dark mode works

setTheme('dark') sets data-ran-theme="dark" on <html>. The stylesheet redefines only the Layer 1 base scale for dark (via a single source of truth in theme/dark.less); every --ran-color-* semantic token references the scale through var(), so it flips automatically. This is why component-level tokens must use dark-safe fallbacks — a fallback should point at a token that flips (var(--ran-color-text, …)), never a light-only literal like rgba(0,0,0,.06).

Released under the MIT License.