They are properties, not variables
A custom property is a real CSS property whose name starts with --. That means it inherits, it takes part in the cascade, and it can be different on different elements. A preprocessor variable is a compile-time find-and-replace; this is not.
:root {
--brand: #f1521f;
--space: 1rem;
--radius: 12px;
}
.button {
background: var(--brand);
padding: var(--space);
border-radius: var(--radius);
}
:root is the html element. Defining there means "available everywhere", because everything inherits from it.
Fallbacks
color: var(--ink, #15161a);
padding: var(--space, var(--space-fallback, 1rem)); /* they nest */
If --gap: banana and you write gap: var(--gap), the browser cannot know it is nonsense until it substitutes — and by then the declaration is "invalid at computed-value time", which makes the property fall back to inherited or initial, not to your previous rule. A typo in a custom property can therefore undo a whole rule.
Scoping is the useful part
Because they inherit, you can redefine a property on a subtree and everything inside it changes. That is how a component takes a variant with one class.
.card {
--card-bg: #ffffff;
--card-ink: #15161a;
background: var(--card-bg);
color: var(--card-ink);
}
.card.dark {
--card-bg: #14161a;
--card-ink: #e9ebee;
}
.card.warning {
--card-bg: #fff6ed;
--card-ink: #7a3b12;
}
The .card rule never changes. Every variant is two lines, and nothing about the component's layout has to be repeated.
Theming
Every rule in the stylesheet refers to var(--bg) and var(--ink). Switching theme is one attribute on <html>, and nothing else in the CSS knows it happened.
:root {
color-scheme: light dark;
--bg: #ffffff;
--ink: #15161a;
--line: rgb(0 0 0 / .12);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0b0d;
--ink: #e9ebee;
--line: rgb(255 255 255 / .12);
}
}
/* and an explicit override wins over the system */
[data-theme="dark"] {
--bg: #0a0b0d;
--ink: #e9ebee;
--line: rgb(255 255 255 / .12);
}
They can hold anything
They can even hold a fragment of a value, though that is usually a step too clever: --hue: 18; color: hsl(var(--hue) 86% 45%); is genuinely useful, because now one number drives a whole palette.
:root {
--shadow: 0 8px 24px -12px rgb(0 0 0 / .4);
--ease: cubic-bezier(.2, .7, .1, 1);
--font: "Space Grotesk", system-ui, sans-serif;
--cols: repeat(auto-fit, minmax(16rem, 1fr));
}
.panel {
box-shadow: var(--shadow);
transition: transform .4s var(--ease);
grid-template-columns: var(--cols);
}
calc and custom properties
:root {
--space: 0.5rem;
}
.tight { padding: var(--space); }
.normal { padding: calc(var(--space) * 2); }
.loose { padding: calc(var(--space) * 4); }
A custom property holding a bare number works in calc(var(--n) 1px). A property holding 10px cannot be multiplied by 2px — calc(10px 2px) is invalid. Keep the unit on one side only.
Reading and writing from JavaScript
This is the cleanest bridge between JavaScript and CSS: the script sets one number, the stylesheet decides what it means. Nothing about the look lives in the script.
const root = document.documentElement;
// read the computed value
getComputedStyle(root).getPropertyValue("--brand").trim(); // "#f1521f"
// write one — this is a normal inline style, so it wins
root.style.setProperty("--brand", "#2f6fed");
// remove it again
root.style.removeProperty("--brand");
// per element: a progress bar with no width calculation in CSS
bar.style.setProperty("--p", 0.62);
.bar::after {
width: calc(var(--p, 0) * 100%);
}
@property
By default a custom property is an untyped string, which is why gradients and transforms built from one cannot be animated. @property gives it a type, an initial value and whether it inherits — and typed properties can be interpolated.
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.ring {
background: conic-gradient(from var(--angle), #f1521f, #14161a, #f1521f);
transition: --angle 1.2s linear;
}
.ring:hover { --angle: 360deg; }
What not to do
- Do not define hundreds of them. A token set of 20–40 that everything refers to is useful; one per declaration is just indirection.
- Name them for their role, not their value:
--line, not--grey-12. The value changes; the role does not. - Do not put the same value in two properties. If
--borderand--dividerare always the same colour, there is one token.