Custom Properties Themes
Build a token-driven theme system, then switch light and dark at runtime by flipping a single attribute.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
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.
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.
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> .
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.
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.
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.
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.