CSS Custom Properties: Beyond Storing Colours
How CSS variables differ from preprocessor variables, why they cascade, and the theming, component-API and animation patterns that only they enable.
Table of contents
- They cascade, which is the whole point
- Theming with one class
- Fallbacks and the fallback trap
- Animating a custom property
- Custom properties as a component API
- Reading and writing from JavaScript
- Frequently asked questions
- Do custom properties hurt performance?
- Can I use them in media queries?
- Should I still use Sass variables?
- Why is my custom property not inheriting?
- Related reading
- References
CSS custom properties are not Sass variables with different syntax. They are live values that participate in the cascade, are readable and writable at runtime, and are inherited — and each of those properties enables something a preprocessor cannot do.
They cascade, which is the whole point#
:root {
--gap: 1rem;
}
.tight {
--gap: 0.5rem;
} /* overrides for this subtree */
.stack {
display: grid;
gap: var(--gap);
}A .stack inside .tight gets 0.5rem. The same component adapts to context without a variant class. A Sass variable is resolved at compile time and cannot do this.
Theming with one class#
:root {
--background: #ffffff;
--foreground: #0b1120;
--brand: #2563eb;
}
.dark {
--background: #0b1120;
--foreground: #e8eef9;
--brand: #3b82f6;
}
body {
background: var(--background);
color: var(--foreground);
}Toggling one class on <html> re-themes the entire page. No duplicate rules, no .dark .card overrides, and any component that uses the tokens gets it for free.
This is why a design system built on custom properties is fundamentally cheaper to theme than one built on utility classes with hard-coded colours.
Fallbacks and the fallback trap#
color: var(--brand, #2563eb); /* single fallback */
color: var(--brand, var(--primary, #000)); /* chained */The trap: an invalid value is not the same as a missing one. var() falls back only when the property is unset, not when it holds nonsense:
--brand: not-a-colour;
color: var(
--brand,
#2563eb
); /* invalid at computed-value time → inherited, NOT the fallback */@property fixes this by giving the variable a type and a real initial value:
@property --brand {
syntax: '<color>';
inherits: true;
initial-value: #2563eb;
}Now an invalid assignment falls back to initial-value instead of breaking the declaration.
Animating a custom property#
Without @property, a custom property is an untyped string and cannot be interpolated — so gradient and shadow animations do not work. With it, they do:
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.spinner {
background: conic-gradient(from var(--angle), var(--brand), transparent);
animation: rotate 2s linear infinite;
}
@keyframes rotate {
to {
--angle: 360deg;
}
}Custom properties as a component API#
This is the pattern that scales best in a design system: a component exposes named knobs, and consumers set them rather than overriding internals.
.button {
--button-bg: var(--brand);
--button-fg: white;
--button-radius: 0.5rem;
background: var(--button-bg);
color: var(--button-fg);
border-radius: var(--button-radius);
}/* A consumer customises without touching .button's rules */
.danger-zone .button {
--button-bg: var(--destructive);
}Because the properties are inherited, this works at any depth — and it does not create a specificity war.
Reading and writing from JavaScript#
const brand = getComputedStyle(document.documentElement)
.getPropertyValue('--brand')
.trim();
element.style.setProperty('--mouse-x', `${event.clientX}px`);Writing a custom property from JS and letting CSS do the work is much cheaper than setting individual styles — the classic example being a mouse-following spotlight effect where JS updates two numbers and CSS handles everything else.
Frequently asked questions#
Do custom properties hurt performance?#
Setting one that many elements depend on triggers style recalculation for that subtree. It is fine for theming and interaction; avoid updating a :root variable on every scroll or pointer event — scope it to the smallest element that needs it.
Can I use them in media queries?#
Not in the query condition — @media (width > var(--bp)) is invalid. Container queries and clamp() cover most of what you would want that for.
Should I still use Sass variables?#
For values that never change at runtime — breakpoint numbers, a spacing scale used in calculations — Sass variables compile away and cost nothing. For anything that varies by theme or context, use custom properties.
Why is my custom property not inheriting?#
Either it is declared with inherits: false via @property, or it is declared on a sibling rather than an ancestor. Custom properties inherit down the tree only.
Related reading#
- Flexbox vs Grid
- CSS Container Queries
- Convert a palette between formats with the Color Converter.