CSS Variables (Custom Properties): A Practical Guide
Basics4 minInnovialabs editorial team
In short
A CSS variable is declared as --name: value and read with var(--name); it inherits down the tree and can change in media queries and from JavaScript right in the browser, which is what sets it apart from Sass variables.
The short answer
CSS variables (officially custom properties) let you define a value once and reuse it in many places. The declaration starts with two hyphens, and you read it with the var() function:
:root {
--color-primary: #7c3aed;
--space-m: 1rem;
}
.button {
background: var(--color-primary);
padding: var(--space-m);
}
Change the value in one place and every button updates. Unlike preprocessor variables, CSS variables live in the browser: you can override them for individual blocks, in media queries and from JavaScript.
Syntax essentials
- The name starts with
--and is case-sensitive:--Colorand--colorare different variables. - The value can be almost anything: a color, a size, a font stack, part of a value.
- A variable is a property, so it is declared inside a selector, not at the top level of the file.
- You cannot use a variable in a media query condition (
@media (min-width: var(--bp))will not work) or as a property name.
Scope and inheritance
Custom properties follow the normal cascade and are inherited by descendants. A variable declared on :root is available everywhere. One declared on a component is available only inside it.
:root { --accent: #7c3aed; }
.card--warning { --accent: #d97706; }
.card .title { color: var(--accent); }
All card titles are violet, but the warning card's are orange. The component reads the same variable and is "configured" from outside. This is the basis for flexible components without a dozen modifiers.
You can override values in media queries too:
:root { --gutter: 1rem; }
@media (min-width: 768px) {
:root { --gutter: 2rem; }
}
Fallback values
The second argument of var() is a fallback, used when the variable is not defined:
.box { padding: var(--box-padding, 1rem); }
One catch: a fallback does not protect against an invalid value. If --size: red and you write width: var(--size), the declaration becomes invalid and the property gets its default value, not the fallback. The browser will not flag this as a syntax error, so check such bugs in DevTools.
Changing variables from JavaScript
This is the main advantage over Sass: values change on the fly.
const root = document.documentElement;
// write
root.style.setProperty('--accent', '#5b21b6');
// read
const accent = getComputedStyle(root).getPropertyValue('--accent').trim();
Typical use cases: cursor position for effects, scroll progress, a user-picked color, theme switching. JS changes one variable, and all display logic stays in CSS.
Design tokens and themes
Design tokens are the named decisions of a design system: colors, spacing, radii, shadows. CSS variables are a convenient way to bring them into code. A good practice is two layers:
- Base tokens — the palette:
--violet-600,--gray-100. - Semantic tokens — roles:
--color-bg,--color-text,--color-accent.
Components use only semantic tokens. A dark theme then becomes an override of a few roles:
:root {
--color-bg: #ffffff;
--color-text: #1a1033;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #120b24;
--color-text: #ede9fe;
}
}
[data-theme="dark"] {
--color-bg: #120b24;
--color-text: #ede9fe;
}
How they differ from Sass variables
| Sass variables | CSS variables | |
|---|---|---|
| When resolved | At build time | In the browser |
| Present in the output CSS | No, replaced with values | Yes |
| Cascade and inheritance | No | Yes |
| Changeable from JS and media queries | No | Yes |
Usable in @media conditions | Yes | No |
They are not rivals. Sass is handy for build-time calculations and mixins; CSS variables are for anything that should change at runtime.
Common mistakes
- Creating a variable for every single value — you end up with hundreds and nobody remembers what each one is for.
- Naming by appearance (
--purple) where a role is needed (--color-accent) — after a rebrand, the name lies. - Ignoring whitespace: in some browsers
getPropertyValuereturns the value with a leading space — addtrim().