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():
: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:
: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:
.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:
.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:
: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;
}
<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>
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.sidebarisn't visible inside a sibling.mainelement. - 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.