Custom Properties for Themes & Dynamic UI

Reviewed & published by Brayan K

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

💡 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>.

<!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-theme, you swap THIS layer. */
  :root {
    color-scheme: light;          /* tells the browser this UI is light */
    --color-bg:      var(--slate-50);
    --color-surface: var(--white);
    --color-text:    var(--slate-900);
    --color-primary: var(--blue-600);
    --color-accent:  var(--blue-100);
  }

  /* ── Dark theme set: same token NAMES, different values. ──
     [data-theme="dark"] on <html> flips every token at once. */
  [data-theme="dark"] {
    color-scheme: dark;           /* native scrollbars/inputs go dark too */
    --color-bg:      #0b1220;
    --color-surface: #1e293b;
    --color-text:    #e2e8f0;
    --color-primary: #60a5fa;
    --color-accent:  #1e3a5f;
  }

  body {
    background: var(--color-bg);          /* reads the SEMANTIC token */
    color: var(--color-text);
    font-family: system-ui, sans-serif;
    padding: 24px;
    transition: background .25s, color .25s;  /* smooth theme swap */
  }

  /* The card hardcodes NOTHING — every colour is a token. */
  .card {
    background: var(--color-surface);
    border: 1px solid var(--color-accent);
    border-left: 4px solid var(--color-primary);
    border-radius: 12px;
    padding: 20px;
    max-width: 420px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.08);
  }
  .card h3 { color: var(--color-primary); margin-top: 0; }

  .toggle {
    background: var(--color-primary);
    color: var(--color-surface);
    border: none;
    padding: 10px 18px;
    border-radius: 8px;
    font-weight: 600;
    cursor: pointer;
    margin-bottom: 20px;
  }
</style>
</head>
<body>
  <button class="toggle" onclick="toggleTheme()">Toggle theme</button>

  <div class="card">
    <h3>Themeable card</h3>
    <p>This card never names a colour. It reads --color-surface, --color-text
       and --color-primary, so swapping the theme set restyles it for free.</p>
  </div>

  <script>
    // Switching at runtime = set/remove one attribute on the <html> element.
    function toggleTheme() {
      var root = document.documentElement;          // the <html> tag
      var isDark = root.getAttribute('data-theme') === 'dark';
      if (isDark) {
        root.removeAttribute('data-theme');         // back to the :root light set
      } else {
        root.setAttribute('data-theme', 'dark');    // activate the dark set
      }
    }
  </script>
  <!-- ✅ Expected result, measured in a real browser:
     .toggle -> cursor: pointer
     .card -> max-width: 420px
  -->
</body>
</html>
The page this code makes: A semantic token layer over primitives, switched at runtime
What this code shows in a browser window 720 pixels wide.

🎯 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.

<!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 primitive */
    --color-primary: ___;
  }

  /* Dark theme set */
  [data-theme="dark"] {
    color-scheme: dark;
    --color-bg:   #052e16;
    --color-text: #dcfce7;
    /* 2) Dark theme needs its OWN value for the SAME token name.  */
    /*    👉 replace ___ with a lighter green like #4ade80          */
    --color-primary: ___;
  }

  body { background: var(--color-bg); color: var(--color-text);
         font-family: system-ui, sans-serif; padding: 24px;
         transition: background .25s, color .25s; }
  button { background: var(--color-primary); color: #fff; border: none;
           padding: 10px 18px; border-radius: 8px; font-weight: 600; cursor: pointer; }
</style>
</head>
<body>
  <button onclick="document.documentElement.toggleAttribute('data-theme')
                   || document.documentElement.setAttribute('data-theme','dark')">
    Toggle theme
  </button>
  <p>The button reads --color-primary, so it recolours when the theme flips.</p>

  <!-- ✅ Expected: a green button on white; after toggle, a brighter green on dark green.
       If the button is invisible, your --color-primary blank is still ___. -->
</body>
</html>

🎯 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.

<!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 shape above and give it warm values. */
  /*    👉 replace ___ with the selector for the sepia theme                        */
  ___ {
    color-scheme: light;
    --color-bg: #f4ecd8; --color-text: #5b4636; --color-primary: #a0522d;
  }

  body { background: var(--color-bg); color: var(--color-text);
         font-family: system-ui, sans-serif; padding: 24px;
         transition: background .25s, color .25s; }
  button { margin-right: 8px; background: var(--color-primary); color: #fff;
           border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; }
</style>
</head>
<body>
  <button onclick="document.documentElement.removeAttribute('data-theme')">Light</button>
  <button onclick="document.documentElement.setAttribute('data-theme','dark')">Dark</button>
  <!-- 2) Make this button activate your new theme.  */
       👉 replace ___ with the theme name you chose above -->
  <button onclick="document.documentElement.setAttribute('data-theme','___')">Sepia</button>

  <p>Three buttons, three themes — every page colour swaps with one attribute.</p>

  <!-- ✅ Expected: Sepia button shows a warm cream page with brown text.
       If Sepia does nothing, the selector and the JS name don't match. -->
</body>
</html>

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.

<!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. Add a button whose onclick toggles data-theme="dark" on <html>.

     ✅ Expected: one card whose background, text and accent all flip
        when you press the button — and you changed no .price-card rules to do it. */

  :root {
    /* your primitive + semantic tokens here */
  }

  /* your [data-theme="dark"] set here */

  body { font-family: system-ui, sans-serif; padding: 24px; }

  .price-card {
    /* read your semantic tokens here — no hardcoded colours */
  }
</style>
</head>
<body>
  <!-- your toggle button + .price-card here -->
</body>
</html>

When to Use This

⚠️ Common Errors

📋 Quick Reference

ConceptSyntaxPurpose
Primitive token--blue-600: #1565c0Raw, 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 runtimehtml.setAttribute('data-theme','dark')Activate a theme set in JS
Native UI matchcolor-scheme: darkTheme scrollbars/controls too
Persist choicelocalStorage.setItem('theme', t)Remember across visits

🎉 Lesson Complete

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
  • Set [data-theme="dark"] (or a class) on <html>
  • Edit the CSS file
  • Call a server API

Answer: Set [data-theme="dark"] (or a class) on <html>. 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

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.