ESC

Type to search the knowledge base.

Theming with Custom Properties

Build light/dark and brand themes with CSS variables — token layers, data-theme switches, component contracts, and FOUC avoidance.

intermediate3 min read
  • css
  • theming
  • custom-properties

Theming is a token problem: components consume semantic variables (--surface, --text, --danger), and themes only reassign those tokens. Custom properties make that assignment cascade with the DOM — no preprocessor rebuild for a dark mode toggle.

Docs: MDN custom properties, color-scheme.

Semantic tokens over raw brand hex

:root {
  color-scheme: light;
  /* palette */
  --blue-600: #0b6bcb;
  --blue-300: #6cb6ff;
  --gray-0: #ffffff;
  --gray-12: #14212b;

  /* semantic */
  --surface: var(--gray-0);
  --text: var(--gray-12);
  --brand: var(--blue-600);
  --border: color-mix(in srgb, var(--text) 12%, transparent);
  --focus: var(--brand);
  --radius: 0.5rem;
  --space-2: 0.5rem;
}

[data-theme="dark"] {
  color-scheme: dark;
  --surface: #0f1419;
  --text: #e7ecf1;
  --brand: var(--blue-300);
  --border: color-mix(in srgb, var(--text) 18%, transparent);
}

Components should reference semantic names:

.card {
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--space-3, 1rem);
}

.button--primary {
  background: var(--brand);
  color: var(--on-brand, white);
}

If a component hard-codes #0b6bcb, theming is already broken.

Theme scopes

<html data-theme="dark">
  <body>
    <main>…</main>
    <aside data-theme="light" class="preview">
      <!-- nested theme island -->
    </aside>
  </body>
</html>

Variables inherit. Nested [data-theme] redefines tokens for a subtree — useful for email previews or theme pickers.

Brand white-label

:root[data-brand="acme"] {
  --brand: #c45c26;
  --radius: 0.25rem;
}

:root[data-brand="globex"] {
  --brand: #0b6bcb;
  --radius: 0.75rem;
}

Ship one CSS bundle; swap attributes per tenant.

Avoid FOUC

<head>
  <script>
    (() => {
      try {
        const t = localStorage.getItem("theme");
        if (t === "light" || t === "dark") {
          document.documentElement.dataset.theme = t;
        }
      } catch {}
    })();
  </script>
  <link rel="stylesheet" href="/app.css" />
</head>

Pair with prefers-color-scheme when no stored preference exists (in CSS defaults).

Component contracts

Document which tokens a component requires:

Token Role
--surface Background
--text Foreground
--border Dividers
--brand Accent actions
--danger Errors

Optional local overrides:

.button {
  --_bg: var(--brand);
  background: var(--_bg);
}

.button--ghost {
  --_bg: transparent;
  color: var(--brand);
  border: 1px solid var(--border);
}

Interview out-loud

“I theme with semantic CSS variables on :root and override them under data-theme or brand attributes. Components never hard-code palette colors. I set color-scheme for native controls, apply the theme early from localStorage to avoid flashes, and allow nested theme scopes when needed.”

Footguns

  1. Mixing semantic and raw palette usage inconsistently.
  2. Animating large sets of variables every frame.
  3. Incomplete dark tokens (one forgotten white modal).
  4. Contrast regressions in one theme only.
  5. Storing theme only in React state without SSR/early paint sync.

Multi-brand + multi-mode

:root,
:root[data-brand="acme"] {
  --brand: #0b6bcb;
}
:root[data-brand="globex"] {
  --brand: #c45c26;
}
:root[data-theme="dark"] {
  --surface: #0f1419;
  --text: #e7ecf1;
  --brand: color-mix(in oklch, var(--brand) 70%, white);
}

Order matters: brand sets the raw accent; theme may re-tint it for dark surfaces. Document the cascade for other engineers. Snapshot tests per brand×theme pair catch missing tokens quickly.

Runtime preview without flash

Theme pickers can set CSS variables on a preview root:

.theme-preview {
  --surface: #0f1419;
  --text: #e7ecf1;
  --brand: #6cb6ff;
  background: var(--surface);
  color: var(--text);
}

Isolating preview tokens prevents the whole app from flashing while the user scrubs options. Commit applies tokens to document.documentElement and localStorage together.

Further reading

Related guides