For years, variables in CSS were the preserve of preprocessors such as Sass or Less. Today CSS has its own custom properties, commonly called CSS variables, and they are far more powerful than preprocessor variables because they live in the browser: they inherit, change according to context and can be modified at runtime.
Basic syntax
A custom property is a property whose name starts with two dashes. You read it with the var() function.
:root {
--primary-color: #1a5fb4;
--space: 1rem;
--radius: 6px;
}
.button {
background: var(--primary-color);
padding: var(--space) calc(var(--space) * 2);
border-radius: var(--radius);
}
The :root selector matches the html element: variables declared there are available throughout the page. Names are case-sensitive, so --Color and --color are two different variables.
How they differ from Sass variables
A Sass variable is substituted at compile time: only the value remains in the final CSS. A custom property, by contrast, really exists in the document and follows the cascade and inheritance like any other property. That means you can redefine it at a specific point in the tree and every descendant will use the new value.
.card {
--primary-color: #26a269;
}
Every button inside .card turns green without touching the .button rule. This mechanism is what makes CSS variables ideal for component variants and themes.
Fallback values
The var() function accepts a second argument that is used when the variable is not defined:
.badge {
color: var(--badge-color, #333);
}
The fallback can itself contain another variable: var(--a, var(--b, black)). Be careful, though: if the variable is defined but holds a value that is invalid for that property, the browser does not use the fallback; it uses the property's initial or inherited value instead. For example, --width: red used in width produces an unexpected result with no visible error.
Light and dark themes
Variables make themes almost trivial. Define colours as semantic tokens and redefine them according to the system preference:
:root {
--background: #ffffff;
--text: #1c1c1c;
--border: #d0d0d0;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #161616;
--text: #ececec;
--border: #3a3a3a;
}
}
body {
background: var(--background);
color: var(--text);
}
If you also want to offer a manual switch, you can add an attribute such as data-theme="dark" on the html element and redefine the same variables for that selector. The rest of the stylesheet stays the same.
Semantic names, not descriptive ones
A tip worth following: name variables after their role (--text, --highlight-background, --error-color) rather than their value (--blue, --light-grey). When the dark theme turns the "light grey" into a dark grey, a descriptive name becomes misleading.
Changing variables from JavaScript
Because they live in the DOM, custom properties can be read and written with the normal style APIs:
const root = document.documentElement;
// read
const color = getComputedStyle(root).getPropertyValue('--primary-color');
// write
root.style.setProperty('--primary-color', '#c01c28');
This opens up interesting uses: passing the mouse position to CSS for a light effect, linking a slider to the text size, or updating a progress bar by setting only --progress and leaving the width calculation to CSS.
.progress-bar {
width: calc(var(--progress, 0) * 1%);
}
Variables and calc()
Custom properties store text, not numbers with units. If you declare --columns: 3, to use it as a length you have to multiply it: calc(var(--columns) * 100px). Conversely, you cannot "glue" a unit onto a variable by writing var(--n)px: it does not work. Keep plain numbers in variables when you need to do maths, and complete units when you need a ready-made value.
@property: typed variables
The @property rule lets you register a variable with a type, an initial value and a choice about whether it inherits:
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
The main benefit is that the browser knows --angle is an angle, so it can animate it. An unregistered variable, on the other hand, switches abruptly during a transition, because the browser does not know how to interpolate generic text.
Common mistakes
- Declaring everything in
:rooteven when a variable is needed by only one component: it is better to declare it on the component itself, so it stays local. - Creating hundreds of variables for values used only once: variables are for what repeats or changes.
- Forgetting the fallback in reusable components, which may end up on pages where the token does not exist.
- Using variables in selectors or in media query conditions: they are not allowed there, because they only work in property values.
In short
- Custom properties start with
--and are read withvar(). - They follow the cascade and inheritance, so they can be redefined for individual components.
- They are the simplest way to handle light and dark themes with semantic tokens.
- JavaScript can read and change them at runtime with
setProperty. @propertymakes them typed and animatable.