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.