Skip to main content
    Courses/HTML & CSS/Custom Properties Themes

    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-scheme so native widgets match your theme
    • Avoid the dreaded theme flash on first paint
    Before this: you should be comfortable declaring custom properties and reading them with 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

    Try it Yourself »
    Code Preview
    <!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

    Try it Yourself »
    Code Preview
    <!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

    Try it Yourself »
    Code Preview
    <!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

    Try it Yourself »
    Code Preview
    <!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: #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

    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

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

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service