CSS Variables & Theming

Custom properties, scoping and fallback values, and a complete light/dark theme-switching example.

What custom properties are

A CSS custom property (informally, a "CSS variable") stores a value under a name you choose, prefixed with --, which can then be reused anywhere with var():

Css
:root {
    --brand-color: #3b82f6;
    --spacing-unit: 1rem;
}

.button {
    background: var(--brand-color);
    padding: var(--spacing-unit);
}

Unlike a Sass or Less variable — which is a build-time text substitution that no longer exists once compiled to plain CSS — a custom property is a genuine runtime value the browser tracks. It can be read and changed by JavaScript, it responds live to a media query changing which value applies, and it participates in the cascade and inheritance exactly like any other CSS property.

Where a custom property is declared matters

:root is the conventional place for global, page-wide tokens — it's a pseudo-class matching the document's root element (<html>), sitting one level "above" html in specificity terms so it's the standard convention for values meant to be available everywhere:

Css
:root {
    --color-text: #1f2937;
}

But a custom property can be declared on any selector, in which case it's only available to that element and its descendants — this is what makes theming by scope possible:

Css
.card {
    --card-padding: 1.5rem; /* only visible inside elements matching .card */
    padding: var(--card-padding);
}

.card.compact {
    --card-padding: 0.75rem; /* overrides it for compact cards specifically */
}

Fallback values with var()

var() accepts an optional second argument used if the custom property isn't defined at all:

Css
.badge {
    color: var(--badge-color, #6b7280); /* falls back to gray if --badge-color was never set */
}

This is especially useful for a reusable component authored once but dropped into different contexts that may or may not define the property it expects.

A complete example: light/dark theme switching

This combines a token set, a manual toggle via a data-theme attribute, and a prefers-color-scheme fallback for visitors who haven't toggled anything yet:

Css
:root {
    --color-bg: #ffffff;
    --color-text: #1f2937;
    --color-surface: #f3f4f6;
    --color-accent: #3b82f6;
}

/* Dark theme, applied when explicitly toggled on */
[data-theme="dark"] {
    --color-bg: #111827;
    --color-text: #f3f4f6;
    --color-surface: #1f2937;
    --color-accent: #60a5fa;
}

/* Respect the OS-level preference until the visitor picks a theme explicitly */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --color-bg: #111827;
        --color-text: #f3f4f6;
        --color-surface: #1f2937;
        --color-accent: #60a5fa;
    }
}

body {
    background: var(--color-bg);
    color: var(--color-text);
    transition: background 0.2s ease, color 0.2s ease;
}

.card {
    background: var(--color-surface);
    border-radius: 8px;
    padding: 1.5rem;
}

.button {
    background: var(--color-accent);
    color: white;
    border: none;
    border-radius: 6px;
    padding: 0.5rem 1rem;
}
HTML
<body>
    <button id="theme-toggle" class="button">Toggle theme</button>
    <div class="card">
        <p>This card's colors update instantly when the theme changes.</p>
    </div>
</body>
Javascript
const toggle = document.getElementById('theme-toggle');
const root = document.documentElement;

// Restore a previously saved preference, if any
const saved = localStorage.getItem('theme');
if (saved) root.setAttribute('data-theme', saved);

toggle.addEventListener('click', () => {
    const isDark = root.getAttribute('data-theme') === 'dark'
        || (!root.hasAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);

    const next = isDark ? 'light' : 'dark';
    root.setAttribute('data-theme', next);
    localStorage.setItem('theme', next);
});

Every element that reads var(--color-bg), var(--color-text), etc. updates the instant data-theme changes on <html> — no JavaScript touches .card or .button directly at all. This is the core payoff of theming with custom properties: the components only ever reference semantic token names, and swapping an entire theme is a one-line attribute change plus a set of variable redefinitions, not a rewrite of every component's CSS.

Custom properties vs. Sass/Less variables

CSS custom properties Sass/Less variables
Resolved At runtime, in the browser At build/compile time
Changeable via JavaScript Yes (element.style.setProperty(...)) No — compiled away entirely
Responds to media queries changing which value applies Yes, natively Only by recompiling separate stylesheets
Scoped to an element/selector Yes — inherits down the DOM tree No — purely a preprocessor text substitution, no runtime scope
Needs a build step No — supported natively in all modern browsers Yes

In practice, many real projects use both together: Sass for compile-time convenience (nesting, mixins, splitting files), and CSS custom properties specifically for anything that needs to change at runtime — like a theme toggle.

Common mistakes

  • Expecting a custom property declared inside a specific selector (like .card) to be usable outside that selector's scope — custom properties inherit down the DOM tree, not sideways or upward, so a variable defined on .sidebar isn't visible inside a sibling .main element.
  • Forgetting the fallback argument in var() when a component might be dropped somewhere its expected custom property was never defined — without it, the property simply won't apply, with no visible error.
  • Confusing a CSS custom property with a Sass variable and expecting Sass tooling (like #{$variable} interpolation) to work on it — they're unrelated systems with different syntax (--name/var(--name) vs $name) and different resolution times.
  • Redefining every individual component's colors directly instead of redefining the small set of shared tokens they reference — this defeats the entire point of theming through variables, since you're back to updating every component individually for a design change.