What are CSS variables — and why they live inside the browser

If you've met variables in Sass or Less, you already have a mental picture: a name instead of a value, so you don't repeat yourself. CSS variables do the same thing — with one difference that unlocks what preprocessor variables never could.
Sass variables disappear at build time: the browser receives a finished #141416. CSS variables arrive in the browser alive. They cascade, they inherit, and they change on the fly. That's exactly why a dark theme flips with one line instead of a rebuild.
What it looks like
You declare a property whose name starts with two dashes. You read it through the var() function.
:root {
--brand: #d4a373;
--gap: 12px;
}
.button {
background: var(--brand);
padding: var(--gap);
}
:root is just the page's root element. Declare there, and it's visible everywhere below. The official name isn't "variable" but custom property — and that's not pedantry: it behaves like a real CSS property, with everything that follows.
var() takes a second argument, a fallback: var(--brand, #d4a373). If the variable isn't declared, the fallback is used.
The key part: they inherit
This is what makes them genuinely useful. A custom property declared with two dashes inherits from its parent and obeys the cascade — just like color or font-size.
Which means you can override it in one spot:
:root { --bg: #141416; }
.card--light { --bg: #f4efe7; }
Everything inside .card--light sees the new --bg; the rest of the page sees the old one. You didn't touch a single rule that uses var(--bg) — you changed the value in one branch of the tree and let it flow down.
That's dark mode in a nutshell: declare a set of colors on :root, then the same set with different values under [data-theme="dark"]. Switching themes becomes swapping one attribute on html. The full build is in how to add a dark theme.
Three gotchas that trip up almost everyone
Case matters. --my-color and --My-color are two different variables. Everywhere else in CSS property case is irrelevant, which makes this one nasty: a typo won't be flagged, it just silently does nothing.
They don't work inside media queries. You can't write @media (min-width: var(--bp)) — MDN says so directly. A variable can only go into a property value, never into a media query condition, a selector, or a property name.
A broken variable doesn't fall back to your previous value. This is the least obvious one. Normally, if a CSS line is nonsense, the browser drops it and uses the previous cascaded value. With var() it can't: by the time the browser realizes the substituted value is meaningless, it has already thrown the alternatives away. The property gets unset — it inherits from the parent, or falls back to the initial value.
In practice: color: var(--accent) with an empty --accent gives you not "the default color from your stylesheet" but the parent's inherited color. It looks like black magic; it's just documented behavior. The cure is a fallback: var(--accent, #d4a373).
Where to declare: :root or the component
Common question: dump everything on :root, or spread it across components? This split works:
- On
:root— whatever describes the product as a whole: palette, grid step, radii, fonts. One source of truth, changed once. - In the component — whatever only makes sense inside it:
--card-padding,--avatar-size. This also gives the component knobs: from the outside you can override one variable without rewriting its styles.
And an important clarification to the second gotcha. Inside a media query you absolutely can declare a variable — you just can't use one in the condition. So this works fine:
:root { --gap: 12px; }
@media (min-width: 900px) {
:root { --gap: 24px; }
}
Every spacing tied to var(--gap) grows at once on wide screens. One media query instead of ten rules — probably the most underrated trick with variables.
Changing them from JavaScript
The best part: you can write a variable from code.
document.documentElement.style.setProperty('--brand', '#62bd98');
One line, and everything wired to it repaints. To read the current value, use getComputedStyle(el).getPropertyValue('--brand').
This is how theme switches, settings sliders and live previews are built. It's also the standard way around the main limitation of variable fonts: put the axis value in a variable and change only that.
How are CSS variables different from Sass variables?
Sass variables only exist until compilation — the browser gets finished values and nothing can change them at runtime. CSS variables live in the browser: they cascade, they inherit, and they're reachable from JavaScript and from devtools.
Why didn't my fallback save me?
Check whether the variable is really undefined. If it is defined but holds a broken value, var() substitutes that value — and the property becomes unset. A fallback only helps when the variable is missing entirely.
What is @property?
A way to declare a variable "with a type": say that it's a color or a length, give it an initial value, and forbid inheritance. Typed variables can animate — plain ones just jump between states. And if your variables look right but the browser disagrees, rule out the basics first: why your CSS isn't applying. If your project runs on utility classes, see how this maps onto Tailwind vs plain CSS — variables sit underneath its theming.
Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.





