Custom Properties Themes
Build a token-driven theme system, then switch light and dark at runtime by flipping a single attribute.
Part of the free HTML & CSS course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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-scheme so native widgets match your theme
- ✓ Avoid the dreaded theme flash on first paint
💡 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> .
🎯 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 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.
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> before the 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.
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: #0f172a in 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 #1565c0 into 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-theme on <html> in a blocking <head> script before the body renders.
- Mismatched token names between themes. If light defines --color-bg but 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: add color-scheme: dark to 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
🎉 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-scheme makes 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.
Practice quiz
What is a primitive design token?
- A token named by role, like --color-primary
- A JavaScript variable
- A token naming a raw value, like --blue-600: #1565c0
- A media query
Answer: A token naming a raw value, like --blue-600: #1565c0. A primitive token names a raw value (--blue-600: #1565c0); semantic tokens point at primitives by role.
What should your components read directly?
- Only semantic tokens
- Only primitive tokens
- Hardcoded hex values
- JavaScript variables
Answer: Only semantic tokens. Components read only semantic tokens, so swapping the semantic layer re-themes everything at once.
What is a 'theme' in this token-based system?
- A new set of components
- A separate CSS file
- A JavaScript function
- The semantic layer redefined with the same names but different values
Answer: The semantic layer redefined with the same names but different values. A theme is a second copy of the semantic layer: same token names, different values.
How do you typically activate a dark theme at runtime?
- Reload the page
Setting data-theme="dark" (or a .dark class) on <html> makes the dark token values win.
What does the color-scheme property do?
- Tells the browser which native UI (controls, scrollbars) to render
- Sets the page background color
- Defines custom properties
- Enables dark mode automatically
Answer: Tells the browser which native UI (controls, scrollbars) to render. color-scheme tells the browser to render native widgets like form controls and scrollbars to match the theme.
Why should each theme set declare its own color-scheme?
- So animations work
- To improve performance
- So native widgets like scrollbars match the theme instead of staying light
- It is required by CSS syntax
Answer: So native widgets like scrollbars match the theme instead of staying light. Without color-scheme, a dark page can keep light scrollbars and white form fields.
What causes the 'theme flash' (FOUC) on page load?
- Too many CSS variables
- Setting the theme after the first paint, e.g. in a React effect
- Using semantic tokens
- A missing caption element
Answer: Setting the theme after the first paint, e.g. in a React effect. The flash happens when JS sets the theme after the body has already painted in the default theme.
How do you prevent the theme flash on load?
- Use !important on every rule
- Avoid dark mode
- Set color-scheme to none
- Run a tiny blocking script in <head> that sets data-theme before the body renders
Answer: Run a tiny blocking script in <head> that sets data-theme before the body renders. An inline blocking <head> script sets data-theme before the first paint so it's already correct.
How would you persist a user's chosen theme across visits?
- sessionStorage only
- localStorage.setItem('theme', 'dark')
- A cookie set by CSS
- It cannot be saved
Answer: localStorage.setItem('theme', 'dark'). localStorage.setItem('theme', value) saves the choice; read it back on load to re-apply it.
If light defines --color-bg but dark defines --bg-color, what goes wrong?
- Nothing, they are aliases
- The page errors out
- The dark theme's background falls back to nothing because the names don't match
- Both backgrounds merge
Answer: The dark theme's background falls back to nothing because the names don't match. Token names must be identical across themes; a mismatch means the component finds no value in that theme.
Continue this course
- Previous: Scroll Animations
- Next: Dark Mode