PVR Tech Studio

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.

8 min read
Updated August 13, 2026

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

FileResponsibility
src/styles/index.cssThe 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.tsThe 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.tsxOwns the oledDark flag (persisted localStorage('oled_dark')), reflected on <html data-oled>; also owns skins (see sibling doc).
src/app/serverPrefs.tsReads 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.tspersist() 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.scssThin, 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>.)

TokenLight (:root)Dark (.dark)Meaning
--background#f6f7f9#0b1120App canvas behind cards
--surface#ffffff#111827Card / panel surface
--surface-muted#f1f5f9#1e293bRecessed surface (inputs, chips)
--foreground#0f172a#e5e7ebPrimary text
--muted-foreground#64748b#94a3b8Secondary / hint text
--border#e5e8ee#1f2937Hairline borders, dividers
--primary#0067ff#2b7fffBrand / action color
--primary-hover#0058db#4d96ffHover state of primary
--primary-foreground#ffffff#ffffffText/icon on a primary fill
--success#16a34a#4ade80Positive tone
--warning#d97706#fbbf24Caution tone
--danger#dc2626#f87171Destructive / error tone
--info#0ea5e9#38bdf8Informational tone
--ring#0067ff#2b7fffFocus ring color

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

var(--surface)), never var(--color-surface), which resolves to transparent. This is why every .scss

Type & radius tokens

TokenValue (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-monoui-monospace, 'JetBrains Mono', …Read by .font-data (tabular numerals).
--radius-card0.75remCard 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.

MemberTypeDescription
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) => voidSet the mode; persists to localStorage('theme'), toggles .dark, notifies subscribers.
setTheme(next)(Theme) => voidBack-compat alias — delegates to setMode.
toggleTheme()() => voidFlip 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:

MemberTypeDescription
oledDarkbooleanTrue-black variant of dark mode.
setOledDark(v)(boolean) => voidToggle 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)), never var(--color-*).
  • Read theme from useTheme() — it's a shared store. Don't re-implement a per-component useState toggle; that desyncs the header from the Customizer.
  • Prefer semantic tokens over literal ones (bg-surface over a specific gray) so skins keep working.
  • Register any new persisted key (if you add a theme-related localStorage key) in src/lib/appStorage.ts so "Reset to defaults" clears it. theme and oled_dark are already covered by the reset flow.

Troubleshooting

SymptomLikely 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 --surface uses --foreground; text/icons on a --primary fill 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-data uses tabular numerals.

Was this page helpful?