Custom properties and theming
Use CSS custom properties as the mechanism behind runtime theming, and avoid treating them like preprocessor variables.

WHAT YOU WILL BE ABLE TO DO
Learning outcomes
- Explain custom properties and theming in plain language.
- Connect the behavior to the underlying browser or framework model.
- Implement the core pattern and reason through edge cases.
- Answer common follow-ups without relying on memorized phrases.
01Explain it simply
A CSS custom property (--name) is an author-defined value that participates in the cascade and inheritance like any other property, and can be read anywhere with var(--name). That combination — cascading, inheritable, and live at runtime — is what makes them the mechanism behind theming, not just a naming convenience for magic numbers.
One-line definition: Use CSS custom properties as the mechanism behind runtime theming, and avoid treating them like preprocessor variables.
02Mental model
Custom properties resolve at computed-value time, not at parse time. Unlike a Sass variable, which is substituted once when the stylesheet compiles, var(--brand) is re-evaluated live whenever the property changes, cascades, or is overridden on a descendant. Because they inherit, redefining one on an ancestor — such as [data-theme="dark"] { --surface: #171a17; } — changes it for every descendant that reads it with var(), without touching the rules that use it.
03Step by step
- Define default values once, usually on :root, using semantic names (--surface, --brand) rather than raw values (--gray-2).
- Reference them everywhere with var(--name, fallback) so a missing definition degrades safely.
- Override a subset on a scoping selector — a class, [data-theme], a component root — to create a theme or local variant.
- Let a media query (prefers-color-scheme) or a class toggle decide which override block is active.
- Keep derived values, like a spacing scale, as custom properties too, so JavaScript and CSS can share one source of truth.
04Working example
:root { --surface: #fffdf7; --text: #171916; --brand: #e4552d;}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --surface: #171a17; --text: #f2f0e8; }}
[data-theme="dark"] { --surface: #171a17; --text: #f2f0e8;}
.card { background: var(--surface); color: var(--text); border: 1px solid var(--brand, #999);}.card never mentions light or dark — it just reads --surface and --text. The actual color values live in exactly two places, the media query and the explicit override, so adding a third theme or changing a shade means editing one block, not hunting through every component that happens to use that color.
05Where it is used
- Light/dark theme toggles without duplicating component styles
- Design tokens shared between CSS and JavaScript via getComputedStyle or inline style props
- Per-component configurable values, such as a --columns property read by that component's grid
- Runtime-adjustable values driven by JavaScript, like a user-set accent color
06Common mistakes
- Treating custom properties as compile-time constants like Sass variables, when they resolve live and can be overridden per subtree
- Defining a raw color palette (--gray-2, --blue-5) instead of semantic roles (--surface, --text), which makes a theme change touch every usage site
- Forgetting that an invalid custom property value falls back to inherited or initial for that property, silently breaking a whole rule
- Not providing a var() fallback for properties that might be undefined in some context, such as inside a portal or third-party embed
07Interview answer
Contrast custom properties with preprocessor variables explicitly — cascade participation, inheritance, and live re-evaluation are the differentiator, and that difference is exactly what makes runtime theming possible without a CSS-in-JS runtime.
Why does overriding --surface on [data-theme="dark"] restyle every card on the page, when .card only defines background: var(--surface) once, in a single rule with no dark-mode variant?
var(--surface) resolves at computed-value time using the cascade and inheritance, so every element under [data-theme="dark"] inherits the overridden value; .card doesn't need a dark-mode variant because it never hardcoded a color, it deferred to whatever --surface currently is.
DDConcept deep dives
Deep dive 1
Custom properties resolve at computed-value time, not compile time
A Sass or LESS variable is textually substituted once, when the stylesheet is compiled; the resulting CSS contains no trace of the variable and can't change without recompiling. A custom property is a genuine part of the cascade: it has a value at every element, that value can be inherited or overridden per subtree, and var() re-evaluates it live any time the winning declaration changes, including in response to a class toggle, a media query, or JavaScript setting it inline. That live, cascading behavior is the entire mechanism behind runtime theming without a CSS-in-JS runtime.
- A build step never needs to run again to change what a custom property resolves to.
- Inheritance is what lets one override on an ancestor reach every descendant using var().
- Because it's live, the same technique also powers user-driven runtime customization, not just fixed themes.
Deep dive 2
Name tokens by role, not by value, or theming stays manual
A palette of raw values, such as --gray-2 or --blue-5, still requires every component to know which raw token means 'this surface's background' in the current theme, so a theme change means editing every usage site or maintaining a mapping table. Naming tokens by role, such as --surface, --text, --brand, moves that decision into the token definition itself: a component asks for --surface once and never needs to know whether the current theme is light or dark. The indirection is what makes adding a theme, or changing what brand means, a one-block edit instead of a codebase-wide find-and-replace.
- A component file should rarely if ever contain a raw hex value once role-based tokens exist.
- Two themes redefining the same role names is cheaper to maintain than two full component stylesheets.
- This is the same principle as design tokens in a design system — the names are the API, the values are an implementation detail.
Deep dive 3
The fallback argument and invalid-value behavior are part of the contract
var(--name, fallback) supplies a value to use if --name isn't defined at that element, which matters for components that might render in a context where the surrounding theme hasn't set every token, such as inside a portal, an iframe, or a third-party embed. Without a fallback, referencing an undefined custom property makes the declaration compute to its inherited or initial value, which can silently produce a very different result than intended, such as a transparent background or an unstyled border, rather than an obvious error.
- Always give a sensible fallback for custom properties read outside the component's own defined scope.
- An invalid value substituted via var() fails at the declaration using it, not at the point the custom property was declared, which can make the failure hard to trace.
- Treat custom properties consumed from an unknown context, such as a shared component library, as effectively public API surface.
QAInterview questions and model answers
Attempt each answer aloud before opening it. The model answer shows the depth and precision expected in an interview; it is not a script to memorize.
Intermediate · Coding · 1 min · Question 1How does a CSS custom property differ from a Sass or LESS variable?Open model answer
Model answer
A preprocessor variable is substituted once at compile time, producing static output CSS with no trace of the variable. A custom property is a real runtime value that participates in the cascade and inheritance; var() re-evaluates it whenever the winning declaration for that property changes, which is what lets one override restyle an entire subtree without recompiling anything.
:root { --gap: 8px; }.tight { --gap: 4px; } /* live override */.stack { gap: var(--gap); } /* re-resolves per element */Intermediate · Conceptual · 1 min · Question 2Why do custom properties inherit by default, unlike most other CSS properties?Open model answer
Model answer
They're defined as inherited properties specifically so a value set high in the tree, commonly on :root, is available to every descendant unless something overrides it closer to the element; that inheritance is what lets one override on a theme class or [data-theme] attribute cascade down to every component using var() beneath it.
Open question page →Advanced · Coding · 1 min · Question 3What happens when var(--missing) references a custom property that was never defined, with no fallback given?Open model answer
Model answer
The property using var() computes to its inherited or initial value, depending on whether it's an inherited property, effectively invalid at computed-value time for that declaration; providing a second argument to var() as a fallback avoids relying on that behavior and keeps the rule predictable.
color: var(--brand, #e2521f); /* fallback used if --brand is undefined */Intermediate · Coding · 1 min · Question 4How would you implement a light/dark theme toggle using custom properties without duplicating component styles?Open model answer
Model answer
Define semantic custom properties such as --surface, --text, and --brand once with default values, write every component against those names via var(), then define exactly one additional override block, keyed by a class, a data attribute, or a prefers-color-scheme media query, that redefines the same property names. Every component that already reads var() repaints without being touched.
:root { --surface: #fff; --text: #15171c; }[data-theme='dark'] { --surface: #171a17; --text: #f2f0e8; }
.card { background: var(--surface); color: var(--text); } /* never theme-specific */Advanced · Coding · 1 min · Question 5Can a custom property hold something other than a plain color or length, like a whole gradient or a comma-separated list?Open model answer
Model answer
Yes — a custom property can hold almost any token sequence, since it isn't type-checked until it's substituted into a property that expects a specific value type. This is powerful, but means an invalid substitution only fails where var() is actually used, not where the custom property was declared.
:root { --stripe: repeating-linear-gradient(45deg, #0001 0 10px, #0000 10px 20px); }.bg { background-image: var(--stripe); }Intermediate · Coding · 1 min · Question 6Why might you scope a custom property to a component root instead of :root?Open model answer
Model answer
Scoping narrows the override's blast radius to one component instance, useful for configurable widgets, such as a --columns value read by that component's grid, or for local variants that shouldn't leak into sibling components; :root should generally hold only genuinely global design tokens.
.masonry { --cols: 3; columns: var(--cols); }.masonry.dense { --cols: 5; } /* only this instance */Intermediate · Coding · 1 min · Question 7How can JavaScript read or set a custom property at runtime?Open model answer
Model answer
getComputedStyle(el).getPropertyValue('--name') reads the resolved value, and el.style.setProperty('--name', value) sets it inline on that element, which then cascades to its descendants exactly like a CSS-authored override would — this is how a user-adjustable accent color or a JS-driven theme switch is typically wired up without a CSS-in-JS runtime.
document.documentElement.style.setProperty('--brand', userColor);getComputedStyle(el).getPropertyValue('--gap').trim(); // "8px"Advanced · Conceptual · 1 min · Question 8What's the practical difference between overriding a custom property on :root versus on html?Open model answer
Model answer
:root refers to the document's root element, the html element in an HTML document, so overriding on :root and on html target the same element and produce identical inheritance behavior; the practical difference is specificity, since :root as a pseudo-class has the specificity of a class selector while html is a type selector with lower specificity.
Open question page →Intermediate · Conceptual · 1 min · Question 9Why can custom properties help reduce specificity wars compared to overriding whole rules per theme?Open model answer
Model answer
Instead of writing higher-specificity or later-cascade rules per theme to override colors on every component, you redefine a handful of custom property values once; the components' own rules never need theme-specific overrides because they were already written generically against var(--name), so there's nothing to out-specify.
Open question page →Advanced · Conceptual · 1 min · Question 10What's a common mistake when using custom properties for a spacing or type scale shared with JavaScript?Open model answer
Model answer
Defining the scale twice, once in CSS custom properties and once in a JS constants file, lets the two drift out of sync; a more robust approach reads the same values from one source, either generating the CSS from the JS tokens at build time or reading the custom property values from computed styles at runtime.
Open question page →SCScenario questions
Scenario 1
A team ships a dark-mode toggle by adding .dark .card { background: #171a17; color: #f2f0e8; } and a similar override for every component class, and every new component now needs its own dark-mode rule written and reviewed separately.
- Identify the small set of semantic colors actually being overridden per theme.
- Move those into custom properties defined on :root and re-declared in one .dark block.
- Rewrite each component to read var(--surface) and var(--text) instead of hardcoded colors.
- Delete the per-component .dark overrides once nothing hardcodes a theme-specific color.
Reveal worked answer
The root issue is that color decisions are duplicated per component instead of centralized. I'd introduce a small set of semantic custom properties, --surface, --text, and a few more, defined with light values on :root and dark values in one .dark block, then update each component to reference var(--surface) and var(--text) instead of literal colors. After that, adding a new component never requires a theme-specific rule, and adding a third theme means adding one more override block, not touching every component again.