Lesson 31 • Advanced Track
Custom Properties for Themes
Build a token-driven theme system, then switch light and dark at runtime by flipping a single attribute.
What You'll Learn
- ✓ Model your UI as design tokens instead of scattered hex codes
- ✓ Layer semantic tokens (--color-bg) over a primitive palette
- ✓ Define light and dark theme sets that share token names
- ✓ Switch themes at runtime with
[data-theme]on<html> - ✓ Use
color-schemeso native widgets match your theme - ✓ Avoid the dreaded theme flash on first paint
var(). If :root { --x: 4px } and color: var(--x) look familiar, you're ready. If not, revisit the earlier CSS variables lesson first.💡 Think of It Like This
A theme system is like a stage lighting board. The primitive layer is the rack of physical bulbs — fixed, named colours sitting in storage. The semantic layer is the labelled channels on the board: "key light", "background", "spotlight" — roles, not bulbs. Your scenery (the components) is wired to the channels, never to a specific bulb. To switch from a "day" scene to a "night" scene, you don't rewire the stage — you push one master fader and every channel re-points at different bulbs at once. That master fader is your data-theme attribute.
1. Two layers: primitives and semantics
A design token is just a named value you reuse everywhere. The trick that makes theming easy is splitting tokens into two layers. The primitive layer holds raw, colour-named values like --blue-600. The semantic layer holds role-named tokens like --color-bg and --color-text that point at primitives.
Your components read only the semantic layer. That single rule is what lets you re-theme an entire app by editing a handful of variables — never the components themselves.
2. A theme set is the semantic layer, redefined
A "theme" is nothing more than a second copy of the semantic layer with the same token names but different values. You scope each copy behind a selector on the <html> element — commonly [data-theme="dark"] or a .dark class. When that selector matches, its token values win, and every component that reads var(--color-bg) instantly updates.
Add color-scheme to each theme set so the browser styles native bits — scrollbars, form controls, the default page background — to match. Without it, your dark page keeps light scrollbars.
Worked example: a themeable card + runtime toggle
Read every comment, then press Toggle theme. Notice the card never names a colour, and the toggle only sets or removes one attribute on <html>.
Themeable card with light/dark toggle
A semantic token layer over primitives, switched at runtime
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* ── PRIMITIVE LAYER: raw, named colours. These never appear in components. ──
Think of these as the paint tins on the shelf — fixed values you mix from. */
:root {
--blue-100: #e3f2fd;
--blue-600: #1565c0;
--slate-50: #f8fafc;
--slate-900: #0f172a;
--white: #ffffff;
}
/* ── SEMANTIC LAYER (light theme): tokens named by ROLE, not colour. ──
Components only ever read these. To re-th
...🎯 Your Turn 1: finish the semantic tokens
The theme set is wired up except for one token in each theme. Fill in the two ___ blanks so--color-primary has a value in both light and dark.
Your Turn: complete --color-primary
Fill the blanks so the button is coloured in both themes
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* 🎯 YOUR TURN — fill in the blanks marked with ___ */
:root {
/* Primitive layer (already done for you) */
--green-50: #ecfdf5;
--green-600: #16a34a;
/* Semantic layer — light theme */
color-scheme: light;
--color-bg: #ffffff;
--color-text: #14532d;
/* 1) Add a semantic token for the button colour. */
/* 👉 replace ___ so --color-primary points at the green primiti
...🎯 Your Turn 2: add a third theme
Light and dark are done. Add a "sepia" theme set behind its own [data-theme] selector, then wire the Sepia button to activate it. The selector name and the JavaScript name must match.
Your Turn: wire a sepia theme
Add a [data-theme] set and a button that switches to it
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* 🎯 YOUR TURN — add a THIRD theme set, then point the button at it */
:root { /* light (default) */
color-scheme: light;
--color-bg: #ffffff; --color-text: #1e293b; --color-primary: #2563eb;
}
[data-theme="dark"] { /* dark */
color-scheme: dark;
--color-bg: #0f172a; --color-text: #e2e8f0; --color-primary: #60a5fa;
}
/* 1) Add a "sepia" theme set below. Copy the sha
...Avoiding the theme flash: if you set the theme in a React effect, the browser paints the default theme first and the user sees a flash. The fix is a tiny blocking script in <head>that reads the saved preference and sets data-theme on <html> beforethe body renders, so the first paint is already correct.
🧗 Mini-Challenge: a two-theme pricing card
No blanks this time — just a comment outline. Build a primitive layer, a semantic layer, a dark theme set, a card that reads only tokens, and a toggle button. The card rules must not change when the theme flips.
Mini-Challenge: themeable pricing card
Build the token layers and toggle from the outline
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* 🎯 MINI-CHALLENGE: a two-theme pricing card
1. Build a PRIMITIVE layer with 2-3 raw colours.
2. Build a SEMANTIC layer on :root: --color-bg, --color-surface,
--color-text, --color-primary (set color-scheme: light).
3. Add a [data-theme="dark"] set that redefines the SAME four tokens
(set color-scheme: dark).
4. Style a .price-card that reads ONLY semantic tokens — no hex codes.
5
...When to Use This
- Light/dark mode: the canonical case — two theme sets, one toggle.
- Brand or white-label theming: ship the same components, swap the semantic layer per client.
- Design systems: a primitive + semantic token split keeps colour decisions in one place.
- User preferences: persist the choice and respect the OS
prefers-color-scheme.
⚠️ Common Errors
- Hardcoding colours instead of tokens. Writing
color: #0f172ain a component means that element ignores every theme. Fix: read a semantic token —color: var(--color-text)— so the theme controls it. - Duplicating the same value across themes. If you paste
#1565c0into five rules, changing the brand colour means five edits and one you'll miss. Fix: define it once as a token and reference it everywhere. - The theme flash (FOUC). Setting the theme after first paint flashes the wrong colours. Fix: set
data-themeon<html>in a blocking<head>script before the body renders. - Mismatched token names between themes. If light defines
--color-bgbut dark defines--bg-color, the dark page falls back to nothing. Fix: keep token names identical across every theme set. - Forgetting
color-scheme. A dark theme with light scrollbars and white form fields looks broken. Fix: addcolor-scheme: darkto the dark theme set.
📋 Quick Reference
| Concept | Syntax | Purpose |
|---|---|---|
| Primitive token | --blue-600: #1565c0 | Raw, colour-named value |
| Semantic token | --color-bg: var(--blue-600) | Role-named; components read these |
| Theme set | [data-theme="dark"] { … } | Redefine tokens for one theme |
| Switch at runtime | html.setAttribute('data-theme','dark') | Activate a theme set in JS |
| Native UI match | color-scheme: dark | Theme scrollbars/controls too |
| Persist choice | localStorage.setItem('theme', t) | Remember across visits |
Frequently Asked Questions
What is the difference between a primitive token and a semantic token?
A primitive token names a raw value (--blue-600: #1565c0). A semantic token names a role and points at a primitive (--color-primary: var(--blue-600)). Components only read semantic tokens, so to re-theme you swap the semantic layer once instead of editing every component.
Should I switch themes with [data-theme] or a class?
Both work the same way — you put a selector on <html> that redefines your tokens. [data-theme="dark"] reads cleanly when a theme is one of several named options; a .dark class is common when you only have light and dark. Pick one convention and use it everywhere so your token sets line up.
What does color-scheme actually do?
color-scheme: light dark (or a single value) tells the browser which native UI to render — form controls, scrollbars and the default canvas colour. Setting it per theme means built-in widgets match your custom theme instead of staying light while everything else goes dark.
How do I stop the page flashing the wrong theme on load?
The flash happens when React/JS sets the theme after the first paint. Fix it with a tiny inline script in <head> that reads the saved preference and sets data-theme on <html> before the body renders, so the very first paint is already correct.
Can I read and persist the user's chosen theme?
Yes. Save the choice with localStorage.setItem('theme', 'dark') when they toggle, and on load read it back and apply it to <html>. To respect the OS preference, fall back to matchMedia('(prefers-color-scheme: dark)') when nothing is saved.
🎉 Lesson Complete
- ✅ Design tokens are named values; splitting them into primitive and semantic layers makes theming trivial
- ✅ Components read only the semantic layer (
--color-bg,--color-text) - ✅ A theme is the semantic layer redefined behind
[data-theme]or a class - ✅ Switching themes at runtime = setting one attribute on
<html> - ✅
color-schememakes native widgets match each theme - ✅ A blocking head script prevents the theme flash on first paint
Next up: apply all of this to a full Dark Mode implementation with persistence and OS-preference detection.
Sign up for free to track which lessons you've completed and get learning reminders.