Design tokens & dark mode
How color, type and radius are defined once as CSS custom properties, mapped into Tailwind v4 utilities, and switched between Light / Dark / System modes (plus true-black OLED) — with never a hardcoded hex in a component.
Overview
Every color in the template lives in exactly one place: CSS custom properties (design tokens). Components never
reference a raw hex value or a raw Tailwind palette color (like bg-slate-800). Instead they use a small set of
semantic utilities — bg-surface, text-foreground, border-border, bg-primary, and so on — that resolve to
whichever token is currently active.
Why this matters:
- Light and dark stay in sync automatically. Because a component only says "use the surface color", flipping to dark mode (or any skin) just swaps the token value — no component edits.
- Skins are free. A whole re-color of the app (see design-skins.md) is nothing more than a block of token overrides.
- One source of truth. A designer can retune the entire product palette by editing a handful of variables in
src/styles/index.css.
Dark mode is a proper Light / Dark / System mode (not a bare on/off toggle). Light is the default on first load; dark only applies when the user chooses it, or chooses "System" while their OS is in dark mode. An optional OLED ( true-black) variant deepens dark mode to pure black for OLED screens.
Architecture & files
| File | Responsibility |
|---|---|
src/styles/index.css | The Tailwind entry (@import 'tailwindcss'). Defines the raw tokens on :root (light) and .dark (dark), the OLED block, the @theme inline mapping into Tailwind utilities, and the CJK font overrides. |
src/hooks/useTheme.ts | The Light/Dark/System mode store (a useSyncExternalStore module store). Persists to localStorage('theme'), toggles .dark on <html>, follows the OS in system mode. |
src/context/LayoutContext.tsx | Owns the oledDark flag (persisted localStorage('oled_dark')), reflected on <html data-oled>; also owns skins (see sibling doc). |
src/app/serverPrefs.ts | Reads the mirrored preference cookies on the server and renders .dark / data-skin / data-oled / lang into the first byte, so there is no flash of the wrong design and nothing to correct after hydration. |
src/lib/appStorage.ts | persist() and SSR_COOKIE_KEYS — the four preferences the first paint depends on are mirrored to cookies so a server can see them at all. |
src/styles/_scrollbar.scss | Thin, theme/skin-aware scrollbar referencing the raw --border / --muted-foreground tokens. |
Key dependencies: Tailwind CSS v4, CSS-first — there is no tailwind.config.js
(wired via @tailwindcss/postcss)
— and React 19.
How the layers connect
preference cookies → serverPrefs.ts reads them during the request
│
<html class="dark" …> → the FIRST byte already carries the right design
│
src/styles/index.css → :root + .dark define raw --* tokens
│ @theme inline
▼
Tailwind utilities → bg-surface, text-foreground, border-border, …
│
Components → use only those semantic utilities
Because the server resolves the design, there is no pre-paint script to keep in sync — with one
exception. theme: 'system' depends on the visitor's OS, which a server cannot know, so that single
case keeps a tiny inline script.
useTheme() and LayoutContext mutate the classes/attributes on <html> at runtime; the pre-paint script mirrors the
same logic so the first paint already matches.
Usage
For non-developers. The theme mode is switched from three places, all kept in sync:
- The header theme control (top bar).
- The Layout Customizer (gear icon → Appearance) — a Light / Dark / System segmented control.
- The Layout Settings page (
/layout-settings).
OLED true-black is a switch in the Customizer's Appearance section (only meaningful while in dark mode).
For developers. Style with the semantic utilities and read the theme from the shared hook:
import {useTheme} from '@/hooks/useTheme'
function Example() {
const {mode, theme, setMode, toggleTheme} = useTheme()
// mode = user's choice: 'light' | 'dark' | 'system'
// theme = resolved value: 'light' | 'dark'
return (
<div className="bg-surface text-foreground border border-border rounded-xl p-4">
<p className="text-muted-foreground">Current mode: {mode}</p>
<button className="bg-primary text-primary-foreground px-3 py-1.5 rounded-lg" onClick={toggleTheme}>
Toggle
</button>
</div>
)
}API / Props
Semantic color tokens
Defined as raw CSS custom properties. Light lives on :root, [data-skin='default']; dark on
.dark, [data-skin='default'].dark. (The [data-skin='default'] mirror keeps a nested "default" swatch — e.g. the
Layout Settings skin picker — on baseline blue even when another skin is active on <html>.)
| Token | Light (:root) | Dark (.dark) | Meaning |
|---|---|---|---|
--background | #f6f7f9 | #0b1120 | App canvas behind cards |
--surface | #ffffff | #111827 | Card / panel surface |
--surface-muted | #f1f5f9 | #1e293b | Recessed surface (inputs, chips) |
--foreground | #0f172a | #e5e7eb | Primary text |
--muted-foreground | #64748b | #94a3b8 | Secondary / hint text |
--border | #e5e8ee | #1f2937 | Hairline borders, dividers |
--primary | #0067ff | #2b7fff | Brand / action color |
--primary-hover | #0058db | #4d96ff | Hover state of primary |
--primary-foreground | #ffffff | #ffffff | Text/icon on a primary fill |
--success | #16a34a | #4ade80 | Positive tone |
--warning | #d97706 | #fbbf24 | Caution tone |
--danger | #dc2626 | #f87171 | Destructive / error tone |
--info | #0ea5e9 | #38bdf8 | Informational tone |
--ring | #0067ff | #2b7fff | Focus ring color |
Info
These are the default skin values. Every skin redefines the same token names — see design-skins.md.
Tailwind utility classes
@theme inline in index.css maps each raw token to a --color-* alias so Tailwind generates matching utilities:
@theme inline {
--color-surface: var(--surface);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
/* …one per token… */
}That yields the utility families you should reach for:
| Utility (representative) | Backed by token |
|---|---|
bg-background | --background |
bg-surface / bg-surface-muted | --surface / --surface-muted |
text-foreground / text-muted-foreground | --foreground / --muted-foreground |
border-border | --border |
bg-primary / text-primary / text-primary-foreground / bg-primary-hover | --primary* |
bg-success / text-success (and warning / danger / info) | --success etc. |
ring-ring | --ring |
Critical Tailwind v4 nuance
@theme inline does not emit the --color-* names as real runtime CSS
variables — they only exist to generate utility class names. So plain CSS / SCSS must reference the raw token (
var(--surface)), never var(--color-surface), which resolves to transparent. This is why every .scss
Info
partial (scrollbar, layout, skins) uses var(--surface), var(--border), etc.
Type & radius tokens
| Token | Value (default) | Notes |
|---|---|---|
--font-sans | 'Plus Jakarta Sans', 'Inter', … | Set in @theme inline; the body font. |
--font-display | 'Inter', … | Skin-aware heading face; read by .font-display. |
--font-mono | ui-monospace, 'JetBrains Mono', … | Read by .font-data (tabular numerals). |
--radius-card | 0.75rem | Card radius; skins override (Bento 1.5rem, Console 0.25rem). |
The @layer components block in index.css exposes two skin-aware helpers:
.font-display { font-family: var(--font-display); }
.font-data { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }CJK languages swap only the font families (via html[lang='ja'] / html[lang='zh'] overriding
--font-sans/-display/-mono to Noto Sans JP / SC) — all other languages keep the default faces.
useTheme() return value
src/hooks/useTheme.ts is a module-level useSyncExternalStore so the header, Customizer and Layout Settings all
read/write one shared value.
| Member | Type | Description |
|---|---|---|
theme | 'light' | 'dark' | The resolved appearance (what .dark reflects). |
mode | 'light' | 'dark' | 'system' | The user's choice. system follows the OS via matchMedia. |
setMode(next) | (ThemeMode) => void | Set the mode; persists to localStorage('theme'), toggles .dark, notifies subscribers. |
setTheme(next) | (Theme) => void | Back-compat alias — delegates to setMode. |
toggleTheme() | () => void | Flip between light and dark based on the resolved theme. |
Persistence key: localStorage('theme') = 'light' | 'dark' | 'system'. In system mode the store re-resolves on OS
theme changes.
Layout context (OLED)
From useLayout() in src/context/LayoutContext.tsx:
| Member | Type | Description |
|---|---|---|
oledDark | boolean | True-black variant of dark mode. |
setOledDark(v) | (boolean) => void | Toggle it; persists to localStorage('oled_dark'), reflects <html data-oled="true">. |
The OLED override block in index.css deepens the neutral tokens (keeping each skin's --primary):
html.dark[data-oled='true'],
.dark.sidebar-oled {
--background: #000000;
--surface: #0a0a0a;
--surface-muted: #161616;
--foreground: #ededed;
--muted-foreground: #8a8a8a;
--border: #242424;
}The same block also serves .dark.sidebar-oled — a true-black sidebar alone on an otherwise-lighter page (see the
layout system's oledSidebar flag).
Configuration & customization
Retune the whole palette: edit the token values in the :root, [data-skin='default'] (light) and
.dark, [data-skin='default'].dark (dark) blocks of src/styles/index.css. Every component updates automatically.
Add a new semantic token: (1) add the raw --myToken to both the light and dark blocks; (2) add
--color-myToken: var(--myToken); inside @theme inline; (3) use it via bg-myToken / text-myToken in components (
or var(--myToken) in SCSS).
Change the OLED palette: edit the html.dark[data-oled='true'], .dark.sidebar-oled block.
Change fonts: update --font-sans (in @theme inline), and --font-display / --font-mono on :root. Skins may
override these — see the sibling doc.
Examples
A themable card that adapts to light/dark/skins/OLED with zero conditional logic:
export function StatTile({label, value}: {label: string; value: string}) {
return (
<div className="bg-surface border border-border rounded-xl shadow-sm p-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="font-data text-2xl text-foreground">{value}</p>
<span className="mt-2 inline-block text-xs text-success">▲ trending up</span>
</div>
)
}Correct SCSS token usage (raw var, not the alias):
/* GOOD — resolves to the live token */
.my-panel {
background: var(--surface);
border: 1px solid var(--border);
color: var(--foreground);
}
/* BAD — --color-surface is transparent in plain CSS */
.my-panel {
background: var(--color-surface); /* ✗ */
}Best practices
- Never hardcode a hex value in a component, and never use raw Tailwind palette colors (
bg-slate-800,text-zinc-500) for themable surfaces. Add or reuse a token instead. - In SCSS, always use the raw token (
var(--surface)), nevervar(--color-*). - Read theme from
useTheme()— it's a shared store. Don't re-implement a per-componentuseStatetoggle; that desyncs the header from the Customizer. - Prefer semantic tokens over literal ones (
bg-surfaceover a specific gray) so skins keep working. - Register any new persisted key (if you add a theme-related localStorage key) in
src/lib/appStorage.tsso "Reset to defaults" clears it.themeandoled_darkare already covered by the reset flow.
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
| A SCSS background comes out transparent. | You used var(--color-surface). Switch to the raw var(--surface) — @theme inline aliases aren't real runtime vars. |
| Flash of the wrong theme on load. | The preference was written with localStorage.setItem instead of persist(), so no cookie was mirrored and the server could not see it. Use persist() for anything in SSR_COOKIE_KEYS. |
| Header toggle and Customizer show different modes. | Something replaced useTheme's module store with local useState. Keep the shared useSyncExternalStore store. |
| Dark mode auto-applies on first visit. | useTheme defaults to 'light'. Dark should apply only for 'dark', or 'system' + OS dark. |
| OLED does nothing. | OLED only deepens dark mode. Ensure the app is in dark (or System→dark) and data-oled="true" is on <html>. |
| A color didn't update in dark mode. | The component used a literal color instead of a token utility. Replace it with bg-*/text-*/border-* semantic classes. |
FAQ
Is dark mode a toggle or a mode? A mode — Light / Dark / System. system tracks the OS live via matchMedia.
What's the default on a fresh browser? Light. Dark never auto-applies unless explicitly chosen (or System + OS dark).
Where is tailwind.config.js? There isn't one. Tailwind v4 is configured CSS-first via @theme inline in
src/styles/index.css.
Does OLED change the accent color? No — it only deepens the neutral surfaces/background; each skin keeps its own
--primary.
Can I use tokens in inline styles? Yes: style={{ background: 'var(--surface)' }} works because the raw tokens are
real CSS variables (unlike the --color-* aliases).
Notes for designers & content editors
- Think in roles, not colors: "surface", "muted text", "primary action", "danger". Pick the token that matches the role and light/dark are handled for you.
- To reskin the product, you rarely touch components — you edit token values. See design-skins.md for shipping a whole new palette.
- Contrast pairs are intentional: text on
--surfaceuses--foreground; text/icons on a--primaryfill use--primary-foreground. Keep those pairings when tuning values. .font-display(headings) and.font-data(numbers/metrics, tabular) are the two type helpers; numbers align in columns because.font-datauses tabular numerals.
Related
Was this page helpful?
