CSS variables (custom properties)
CSS custom properties are user-defined CSS properties starting with -- that store reusable values, resolved dynamically during the cascade like any other property, with full inheritance down the DOM tree.
Theory
TL;DR
- CSS variables are like a shared config file for your styles: define a color once in
:root, every component reads from there, and you change one line to update everything. - Main difference from Sass variables: Sass compiles them to static values before the browser ever sees the file. CSS variables live in the DOM and respond to JS changes at runtime.
- Use when a value appears in 3+ places, or when you need runtime theme switching. For one-off values, just hardcode.
- Syntax:
--name: valueto define,var(--name)to use,var(--name, fallback)to use safely. - Scope follows DOM inheritance: children inherit from parents, siblings do not share.
Quick example
/* Define once in :root - available to all elements */
:root {
--brand-blue: #1e40af;
--gap: 1rem;
}
/* Use anywhere */
.primary-btn {
background: var(--brand-blue);
padding: var(--gap);
}
/* Override for dark theme - only affects descendants */
.dark-theme {
--brand-blue: #60a5fa;
}
/* Inside .dark-theme, buttons show #60a5fa; outside, #1e40af */That is the whole pattern. Define at :root, use via var(), override in a narrower selector.
Cascade and inheritance
CSS variables participate in the normal cascade. A child element gets its closest ancestor's value for a given variable. If .card defines --color: red and you nest .card-title inside it, .card-title inherits --color: red without any extra declaration.
This is why :root works as a global default: it sits at the top of the DOM tree, so every element inherits from it. But variables are scoped to the element subtree. Two sibling elements cannot share a variable defined on one of them.
Specificity works exactly as with regular properties. A .dark-theme class redefines --brand-blue and every descendant immediately picks up the new value. No JavaScript needed for that part.
When to use
- Theme switching (light/dark): define tokens in
:root, toggle a class ordata-themeattribute with JS. - Design tokens:
--space-xs: 0.25rem,--space-sm: 0.5remin a token file, referenced across components. - Component-level overrides: set a variable on the component root, children inherit it automatically.
calc()with a shared base:padding: calc(var(--gap) * 2)removes magic numbers from your codebase.- Skip for: one-off values, or any project that must support IE11. IE11 has zero support. Use Sass or
postcss-css-variablesas a fallback there.
How the browser handles this
Browsers resolve custom properties during the style resolution phase, after the cascade has sorted specificity and importance. The engine (Chrome's Blink, Firefox's Stylo) stores each custom property in the element's computed style map. When it encounters var(--foo), it substitutes the inherited or locally defined value at that point. No ahead-of-time compilation.
JS updates trigger a partial reflow only for properties that use the changed variable:
// Read - returns '' if not set, not null
const val = getComputedStyle(el).getPropertyValue('--brand-blue');
// Write on the root
document.documentElement.style.setProperty('--brand-blue', '#60a5fa');
// Write with !important - overrides author styles
el.style.setProperty('--foo', 'red', 'important');One thing I see consistently in code reviews: developers check if (val === null) and the check always passes because an unset variable returns an empty string, not null. Use if (val.trim()) or a strict equality check against ''.
Common mistakes
Expecting global scope without :root
/* Wrong */
.header { --color: blue; }
.footer { color: var(--color); } /* Unresolved - .footer is not inside .header */
/* Fix */
:root { --color: blue; }Variables scope to the element subtree. Siblings do not inherit from each other.
No fallback for missing variables
/* Wrong - falls back to browser default silently */
color: var(--missing-color);
/* Fix */
color: var(--missing-color, #333);Invalid value with no fallback
:root { --size: red; }
.box {
width: var(--size); /* red is invalid for width - property is ignored */
width: var(--size, 40px); /* fallback kicks in: 40px used */
}This one catches senior developers too. The spec says: if substitution results in an invalid value for that property, the property becomes invalid at computed-value time. With a fallback, the fallback wins.
Using variables in media query conditions
/* Does not work */
:root { --bp-mobile: 768px; }
@media (max-width: var(--bp-mobile)) { ... } /* Invalid syntax */Variables only work inside declaration blocks, not in media query conditions.
Real-world usage
- Tailwind CSS uses
--tw-bg-opacity: 1as part of its utility color system. - Bootstrap 5 ships
--bs-primary: #0d6efdin:root, powering all component styles from a single token. - Material UI exposes
--mui-palette-primary-mainfor design token-based theming. - Chakra UI scopes
--chakra-colors-blue-500to its Provider component, not to:root.
The pattern across all of them is the same: define a token map once, reference tokens in component styles, change the map to change the theme.
Follow-up questions
Q: What is the var() fallback syntax and what happens with multiple commas?
A: var(--foo, red) uses --foo if valid, otherwise red. The syntax var(--foo, red, blue) is technically valid, but everything after the first comma is treated as a single fallback value. So the fallback here is red, blue as one token, not two separate options.
Q: How do CSS variables differ from Sass variables in the cascade?
A: Sass compiles its variables to static values before the browser parses the file. The browser never sees $color, only #1e40af. CSS variables are resolved at runtime per element, which means JS can update them and they respond to DOM structure changes.
Q: Can a CSS variable reference another variable?
A: Yes. --double: calc(var(--base) * 2) resolves fully at computed time. Circular references are detected and treated as invalid.
Q: Why does updating a CSS variable via JS not update the element visually?
A: Usually a specificity issue. If an inline style or a more specific selector sets the property to a static value, the variable change has no effect on that property. Open DevTools, check the computed styles, and see which declaration wins.
Q: What does setProperty with 'important' as the third argument do?
A: el.style.setProperty('--foo', 'red', 'important') marks the custom property as !important, which means it overrides author-level styles. Useful in rare debugging scenarios, not something to reach for in production.
Examples
Theme toggle with a data attribute
:root {
--bg: #ffffff;
--text: #111111;
--accent: #1e40af;
}
[data-theme="dark"] {
--bg: #111111;
--text: #f0f0f0;
--accent: #60a5fa;
}
body {
background-color: var(--bg);
color: var(--text);
transition: background-color 0.2s, color 0.2s;
}
.cta-btn {
background: var(--accent);
}function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
}Change one attribute on <html> and every component that references the tokens updates. No JS loop over individual properties, no re-renders.
Component-scoped tokens with calc()
:root {
--base-space: 1rem;
}
.card {
--card-space: var(--base-space); /* inherits from :root */
padding: var(--card-space);
gap: calc(var(--card-space) / 2);
}
.card--compact {
--card-space: 0.5rem; /* one override, both padding and gap adjust */
}One token controls multiple properties at once. Override it in the modifier and the whole component recalculates.
Fallback behavior for invalid and missing values
:root {
--size: 20px;
}
.element {
width: var(--size, 30px); /* --size is valid: 20px used */
}
.broken {
--size: red; /* invalid for width */
width: var(--size, 40px); /* invalid skipped, fallback 40px used */
}
.missing {
/* --undefined-var is not set anywhere in the tree */
width: var(--undefined-var, 50px); /* fallback 50px used */
}The spec treats an invalid substitution the same way it treats a missing variable: the fallback wins. This behavior is what surprises most developers the first time they hit it.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.