CSS Custom Properties: Variables in CSS Explained
CSS custom properties are native variables that cascade, inherit, and update live at runtime. How they work, why they beat preprocessor variables.
CSS custom properties — commonly called CSS variables — let you store a value once and reuse it anywhere in your stylesheet, using the --name syntax to define and var() to read. They look like a small convenience, but unlike the variables in preprocessors such as Sass, custom properties are live: they exist in the browser at runtime, they cascade and inherit through the DOM like any other property, and you can read and change them with JavaScript while the page is running. That combination — real variables that respond to the cascade and to script — is what makes them one of the most useful features in modern CSS.
The basic syntax
You define a custom property by giving it a name that starts with two dashes, and you read it back with the var() function:
:root {
--brand: #6b46c1;
--space: 1rem;
}
.button {
background: var(--brand);
padding: var(--space);
}
Defining them on :root (the <html> element) makes them available globally, because custom properties inherit down the tree. But you’re not limited to :root — you can define a property on any selector, and it applies to that element and its descendants. That locality is the first thing that sets custom properties apart from a global preprocessor variable.
var() also accepts a fallback as its second argument, used when the property isn’t defined:
.card {
color: var(--text-color, #333);
}
They cascade and inherit — that’s the point
The word “custom property” is deliberate: these are real CSS properties that happen to have custom names, so they obey the cascade and inheritance rules that govern everything else, subject to the usual specificity considerations. That has a powerful consequence — you can redefine a variable partway down the tree and everything below it picks up the new value.
.theme-dark {
--bg: #111;
--text: #eee;
}
.card {
background: var(--bg);
color: var(--text);
}
Wrap a section in .theme-dark and every .card inside it flips to dark values, with no extra rules and no duplicated selectors. This scoping-by-context is impossible with preprocessor variables, which are resolved and thrown away at build time before the browser ever sees them.
The superpower: they’re live at runtime
Because custom properties exist in the running browser, JavaScript can read and write them on the fly:
document.documentElement.style.setProperty('--brand', '#e11d48');
Change one property and every rule that references it updates instantly — no class swapping, no re-rendering a component tree. This is why custom properties are the standard mechanism behind runtime theming, user-adjustable settings, and values driven by pointer position or scroll. A single --accent property on :root can retheme an entire application from one line of script.
They also compose cleanly with calc(), which unlocks patterns that were genuinely awkward before:
.grid {
--columns: 3;
--gap: 1rem;
gap: var(--gap);
grid-template-columns: repeat(var(--columns), 1fr);
}
Now a single --columns value drives the layout, and you can change it per breakpoint or per container. It pairs naturally with modern layout tools like CSS Grid and with container queries, where a component adapts to the space it’s actually given rather than the viewport.
Custom properties vs preprocessor variables
Both give you named, reusable values, but they operate at different times and in different places:
| CSS custom properties | Preprocessor variables (Sass) | |
|---|---|---|
| Resolved | At runtime, in the browser | At build time |
| Cascade & inherit | Yes | No |
| Scoped to elements | Yes — per selector | No — file/block scope |
| Readable from JavaScript | Yes | No |
| Update live on the page | Yes | No |
| Works without a build step | Yes | No |
They aren’t strictly rivals — plenty of codebases use Sass for build-time logic like loops and mixins while using custom properties for anything that needs to change at runtime. But for values a user or script might alter, native custom properties are the clear choice.
Practical patterns
- A design-token layer. Define spacing, color, and typography scales as custom properties on
:root, then reference them everywhere. Changing the design system becomes editing a handful of values. This is essentially how utility-first frameworks expose their theme, and Tailwind CSS v4 leans on native custom properties for exactly this. - Theming. Keep a light-theme block and a dark-theme block that redefine the same property names, and toggle a class on
<html>— or drive it from aprefers-color-schememedia query. - Component APIs. Expose a component’s tunable bits as custom properties (
--card-padding,--card-radius) so consumers can adjust them without overriding your selectors and fighting specificity. - Reducing repetition. Any value repeated across rules — a brand color, a border radius, a transition duration — becomes a single source of truth.
A couple of gotchas
- They’re case-sensitive.
--Brandand--brandare different properties. - The value is substituted as text.
var()performs a token substitution; an invalid resolved value falls back to the property’s initial or inherited value, which can produce surprising results if you’re not expecting it. - Inheritance means scope matters. A property defined on a deep element isn’t visible to its ancestors or siblings — only its descendants. If a
var()isn’t resolving, check where it was defined.
The takeaway
CSS custom properties are the real thing: native variables that cascade, inherit, scope to elements, and — uniquely — stay live in the browser so JavaScript can change them and the whole page reacts. Use them for design tokens, theming, and any value that might change at runtime, and reach for preprocessor variables only when you need build-time logic. Once a --accent property can retheme your app from one line, you’ll wonder how CSS went two decades without them.
Tagged
Keep reading
Takina · · 4 min read CSS object-fit and object-position, Explained
object-fit controls how an image or video is cropped inside its box, and object-position controls which part of it stays visible. How they work together.
Takina · · 5 min read CSS inherit, initial, unset & revert Explained
CSS's four global keywords control where a property's value comes from. How inherit, initial, unset, and revert differ, with a comparison table.
Takina · · 4 min read Dynamic Viewport Units: dvh, svh, and lvh Explained
dvh, svh, and lvh fix the classic mobile vh bug where browser toolbars cut off full-height layouts. Here's what each unit measures and when to use it.