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.

What Geist is: Geist is Vercel's open-source design system — the one behind vercel.com and the Next.js docs. Its defining idea is that color is a state ladder, not a palette: each color scale runs from step 100 to 1000, and every step has one fixed job (100 default background · 200 hover · 300 active · 400 border · 500 hover border · 600 active border · 700 solid fill · 800 solid hover · 900 secondary text · 1000 primary text). ranui adopts that ladder as its --ran-* scales, layers semantic tokens (--ran-color-bg, --ran-color-text, --ran-color-border, …) on top, and ships Geist Sans / Geist Mono as the default typefaces. The practical payoff: dark mode only has to redefine the base scale — every semantic token references it and flips automatically, so components stay dark-safe for free. See Token layers and Fonts below for the full ladder.

Use when you need to add light/dark theming to a ranui app — call initTheme / setTheme and consume the semantic --ran-color-* design tokens so switching theme or overriding a token restyles the whole library at once.

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/theme';

// 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' | ''

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 if you prefer a single import.

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.

For ready-made theme-switching UI, use the <r-theme-switch> component — a system / light / dark segmented control wired to this API.

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 (monochrome)
--ran-color-primary-hoverPrimary hover
--ran-color-primary-activePrimary active
--ran-color-primary-textInverse ink on a primary surface
--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

The primary group is the monochrome "highest-contrast" action (the Geist brand tone, <r-button type="primary">): black-on-white in light mode, white-on-black in dark. It carries no hue — blue is reserved for links (--ran-color-link) and the focus ring only. --ran-color-primary-text is the inverse ink for text/icons sitting on a primary surface (it flips too, so a primary button reads correctly in both themes).

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)

Fonts

ranui self-hosts the canonical faces behind --ran-font-family / --ran-font-monoGeist Sans and Geist Mono (variable weight 100–900, SIL OFL 1.1 licensed). They are shipped with the package, so one import loads them with no CDN dependency:

js
// bundlers
import 'ranui/fonts';
html
<!-- static pages -->
<link rel="stylesheet" href="…/ranui/dist/fonts/fonts.css" />

Without this import the typography tokens fall back to system fonts — everything still works, just without the Geist faces.

Customizing tokens

At runtime (JS)

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