Chapter 09

Theming

One attribute flips the whole world. Light and dark are not two stylesheets; they are one set of names that resolve to different values.

Built with

  • data-theme
  • localStorage
  • Inline boot script

The mechanism

One attribute flips the whole world.

The root element carries data-theme, either light or dark. The semantic tokens are defined for light, and the dark block overrides only the ones that have to change. Components never ask which theme is active; they just read var(--background).

Proof

Side by side, same markup.

Each panel below is forced to a theme with its own attribute, so you can compare both at once whatever the page is set to.

light

Quiet by default.

The same markup, resolved by a different attribute.

Say helloNext.jsonline

dark

Quiet by default.

The same markup, resolved by a different attribute.

Say helloNext.jsonline

The delta

What dark changes.

24 tokens differ in dark. Everything else, including the radii, fonts, spacing and every primitive, is shared. Each pair below is live.

  • --background

    light
    dark
  • --background-elevated

    light
    dark
  • --background-surface

    light
    dark
  • --foreground

    light
    dark
  • --foreground-muted

    light
    dark
  • --foreground-subtle

    light
    dark
  • --primary

    light
    dark
  • --primary-light

    light
    dark
  • --primary-dark

    light
    dark
  • --secondary

    light
    dark
  • --accent-warm

    light
    dark
  • --accent-coral

    light
    dark
  • --border

    light
    dark
  • --border-subtle

    light
    dark
  • --success

    light
    dark
  • --error

    light
    dark
  • --badge-date-fg

    light
    dark
  • --badge-date-bg

    light
    dark
  • --badge-date-border

    light
    dark
  • --chat-header-bg

    light
    dark
  • --chat-header-fg

    light
    dark
  • --chat-glass-bg

    light
    dark
  • --heatmap-l0

    light
    dark
  • --heatmap-l4

    light
    dark
globals.css · the whole dark theme
[data-theme="dark"] {  --background: var(--ink-950);  --background-elevated: var(--ink-900);  --background-surface: var(--ink-800);  --foreground: var(--cream-50);  --foreground-muted: var(--grey-dark-muted);  --foreground-subtle: var(--grey-dark-subtle);  --primary: var(--violet-400);  --primary-light: var(--violet-300);  --primary-dark: var(--violet-550);  --secondary: var(--cream-300);  --accent-warm: var(--peach-300);  --accent-coral: var(--peach-400);  --border: rgba(250, 227, 198, 0.12);  --border-subtle: rgba(250, 227, 198, 0.07);  --success: var(--green-400);  --error: var(--red-400);  --badge-date-fg: var(--peach-300);  --badge-date-bg: rgba(245, 201, 138, 0.12);  --badge-date-border: rgba(245, 201, 138, 0.28);  --chat-header-bg: var(--violet-200);  --chat-header-fg: var(--violet-950);  --chat-glass-bg: color-mix(in srgb, var(--ink-900) 68%, transparent);  --heatmap-l0: rgba(244, 242, 248, 0.05);  --heatmap-l4: var(--primary-light);}

Polish

No flash on load.

A tiny script in the document head sets the attribute before the first paint, using the saved choice and defaulting to dark. Storage can be blocked, so it is wrapped in a try, and the page falls back to dark rather than failing.

layout.tsx · inline boot script
// Runs in <head>, before first paint, so the page never flashes the wrong theme.try {  var t = localStorage.getItem('theme');  document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');} catch (e) {  document.documentElement.setAttribute('data-theme', 'dark');}

Growing

Adding a theme.

A third theme is just another block of overrides. Because components only know token names, nothing else in the codebase needs to learn that it exists.

A hypothetical third theme
/* 1. Override only the semantic tokens that should differ */[data-theme="dusk"] {  --background: var(--violet-950);  --foreground: var(--cream-50);  --primary: var(--violet-300);} /* 2. Nothing else changes. Components already read var(--token). */