Custom Properties and Theming
IntermediateCSS variables are live values that inherit and cascade, which makes theming, dark mode and component variants a matter of redefining tokens rather than rewriting rules.
Overview
Preprocessor variables are compile-time substitutions; CSS custom properties are real values in the cascade. They inherit, they can be redefined for a subtree, they can be read and written from JavaScript, and they update live. That difference is what makes them the mechanism behind every modern theming system: define a set of tokens once, redefine the tokens under a dark-mode media query or a data attribute, and every rule that references them follows. No component CSS needs to know that a theme exists.
Tokens
Define at the root, reference everywhere, and layer semantic names over raw values.
:root {
/* primitives — the raw palette */
--blue-600: #2563eb;
--grey-900: #111827;
/* semantic tokens — what the UI actually references */
--color-accent: var(--blue-600);
--color-text: var(--grey-900);
--color-surface: #ffffff;
--space-1: .25rem; --space-2: .5rem; --space-4: 1rem;
--radius: .5rem;
--shadow-sm: 0 1px 2px rgb(0 0 0 / .06);
}
.button { background: var(--color-accent); border-radius: var(--radius) }
/* Two layers matters: components reference MEANING, so swapping the
palette changes one line rather than forty. */
/* Fallbacks for a possibly-undefined variable */
color: var(--color-text, #111)
padding: var(--card-padding, var(--space-4, 1rem))
/* They inherit, so a subtree can override */
.card { --color-accent: var(--green-600) } /* only inside this card */Dark Mode
Three states — system, explicit light, explicit dark — handled by redefining tokens only.
/* 1. Light is the base */
:root {
--bg: #ffffff; --text: #111827; --rule: #e5e7eb;
}
/* 2. System dark, unless the user explicitly chose light */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0b0f14; --text: #e5e7eb; --rule: #1f2937;
}
}
/* 3. An explicit choice wins in both directions */
:root[data-theme="dark"] {
--bg: #0b0f14; --text: #e5e7eb; --rule: #1f2937;
}
body { background: var(--bg); color: var(--text) }
/* Every component now themes itself with no dark-mode rules at all.
The bug to avoid: defining a colour ONLY inside the media query,
so it is undefined for a user whose system is light. */
/* Set the toggle from JavaScript */
document.documentElement.dataset.theme = 'dark'
/* And tell the browser, so form controls and scrollbars follow */
:root { color-scheme: light dark }Dynamic Values
Where custom properties do things a preprocessor variable cannot.
/* Read and write from JavaScript — live */
el.style.setProperty('--progress', '0.4')
getComputedStyle(el).getPropertyValue('--color-accent')
/* Which enables patterns that would otherwise need a class per value */
.bar { width: calc(var(--progress) * 100%) }
.card { transform: rotate(calc(var(--i) * 2deg)) }
/* Passing a value from HTML into CSS */
<div class="chart" style="--value: 72">
.chart::after { height: calc(var(--value) * 1%) }
/* @property gives a variable a type, so it can ANIMATE — plain
custom properties cannot be interpolated */
@property --angle {
syntax: '<angle>'; initial-value: 0deg; inherits: false;
}
.spinner { transition: --angle .3s; background: conic-gradient(…, var(--angle)) }
/* Component variants without variant-specific rules */
.button { background: var(--btn-bg, var(--color-accent)) }
.button--danger { --btn-bg: var(--color-danger) }
/* Scoped to a breakpoint — one variable, one media query, and every
rule that uses it responds */
@media (min-width: 768px) { :root { --space-section: 6rem } }Key Points to Remember
- 1Custom properties are live cascade values, not compile-time substitutions — they inherit and can be redefined per subtree
- 2Layer semantic tokens over raw primitives so a palette change touches one place
- 3Theming means redefining tokens in three blocks — base, prefers-color-scheme, and an explicit data-theme
- 4Defining a colour only inside a dark-mode media query leaves it undefined in light mode
- 5They can be read and written from JavaScript, and @property gives them a type so they can animate
Interview Questions
Sign in to ask AriaHow do CSS custom properties differ from Sass variables?
How would you implement a dark mode that respects both the system setting and an explicit toggle?
Why can a plain custom property not be animated, and what fixes that?
Ask Aria about Custom Properties and Theming
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.