Design Tokens & CSS
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.
Overview
Every color in Luminaux 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) 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). |
index.html | Pre-paint inline script that applies the saved theme (.dark), skin (data-skin), OLED (data-oled) and language (lang) before first paint so there is no flash of the wrong design. |
src/styles/_scrollbar.scss | Thin, theme/skin-aware scrollbar referencing the raw --border / --muted-foreground tokens. |
Key dependencies: Tailwind CSS v4 via @tailwindcss/vite (CSS-first config — there is no
tailwind.config.js); React 19.
How the layers connect
index.html (pre-paint) → sets .dark / data-skin / data-oled / lang on <html>
│
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
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>
)
}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 |
Default skin values
These are the default skin values. Every skin redefines the same token names — see Design Skins.
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 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); /* wrong */
}The pre-paint script (already in index.html) that prevents a flash:
<script>
;(function () {
try {
var t = localStorage.getItem('theme')
if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
}
if (localStorage.getItem('oled_dark') === 'true') {
document.documentElement.dataset.oled = 'true'
}
} catch (e) {}
})()
</script>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.
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 pre-paint script in index.html was removed or the key name changed. It must run before paint and read localStorage('theme') / 'oled_dark'. |
| 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'; check the pre-paint script isn't adding .dark unconditionally. 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 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
Design Skins
The 13 switchable palettes/designs built entirely on these tokens.
Customizer & Settings
Where the theme mode and OLED switches live.
Layout & Shell
The header theme control and the DesignSystemMenu skin switcher.
Architecture
Project structure, providers, and the routing model.
Getting Started
Install, run, and add your first page.
Was this page helpful?
