Learn CSS · Lesson 9 of 11 · 9 min
Custom properties as a system
Widely availablesince Oct 2019 Newly availablesince Jul 2024
A custom property looks like a variable and behaves like a CSS property, which is the better deal. It follows the cascade, it inherits, it can be overridden for one component, and it can be changed while the page is open. A preprocessor variable is gone by the time the browser sees the file.
Three tiers
The arrangement that survives a redesign has three levels, and each level only talks to the one above it. Primitives are raw values with no opinion. Roles say what a value is for. Component properties belong to one component and read from roles. A rebrand edits the first tier, dark mode edits the second, and no component changes at all.
Invoice 0042
Ready to send
Three line items, due in 14 days.
Send invoice The sliders only ever touch tier one. The button’s background is
var(--button-bg), which is var(--accent), which is built from
var(--hue). Two hops, and every hop is a place where you can step in.
Six things to know
Read with a fallback
.badge {
background: var(--badge-bg, var(--accent, rebeccapurple));
} Goes on: any declaration that reads a property it does not own.
The second argument is used when the property is not set at all. It can be another
var(), so a component can offer its own hook, fall back to the system’s role, and still
work dropped into a page that has neither.
Override for one component, not the page
.chip { background: var(--chip-bg, #e7e9fb); }
.chip.danger { --chip-bg: #fde2e1; }
.chip.quiet { --chip-bg: transparent; } Goes on: the variant. The base rule never changes.
The variant sets a property and the base rule reads it. There is one background
declaration in the whole component, so there is nothing to out-specify later. This is inheritance
doing the work: the value is set on the chip and anything inside it sees the same value.
What it replaced: a second full rule per variant, each repeating the property.
The trap: a bad value does not fall back
.note {
color: red;
color: var(--oops); /* --oops: 12px */
} Goes: nowhere. Recognise it when text turns an unexpected colour.
With an ordinary typo the browser drops the bad line and keeps red. With
var() it cannot know the value is bad until it has already thrown the earlier
declaration away, so the property ends up as if you had never set it: inherited or initial. The
fallback argument does not help either, because the property is set. Card four is the cure.
Give a property a type
Newly available@property --angle {
syntax: '<angle>'; inherits: false; initial-value: 0deg;
}
.ring { background: conic-gradient(from var(--angle), …);
transition: --angle .8s; } Your browser does not support this yet, so the demo above shows the fallback. Without @property the variable is just a string, so the gradient snaps to its new angle on hover instead of sweeping. Same end state, no motion.
Goes: at the top level of a stylesheet, once per property.
A typed property rejects a value of the wrong type and uses its initial-value, which
closes the trap in card three. It can also be animated, because the browser now knows how to get
from one angle or colour to another. Build one in the @property builder.
Change it from JavaScript
el.style.setProperty('--progress', 0.62);
.bar::after { scale: var(--progress) 1; } Goes on: the element that owns the state.
JavaScript supplies a number and CSS decides what the number looks like. The script never learns about colours, widths or transitions, so a redesign does not touch it. The sandbox above does exactly this with three numbers.
What it replaced: scripts writing style.width and
style.backgroundColor directly.
Let children react to a property
Newly available.card { --density: compact; }
@container style(--density: compact) {
.card-meta { display: none; }
.card-title { font-size: 1rem; }
} Goes on: descendants of the element that sets the property.
A style query asks “does an ancestor have this value?”. One property on the parent can switch a whole set of rules in its children, with no modifier class repeated down the tree. Check the badge before relying on it. A browser without it skips the block, so put the default look outside.
Practice
- In the sandbox, tick “Dark roles” and read the code. Which tier changed? Which did not?
- Take a button in a project of yours that has a hover colour typed by hand. Replace it with
color-mix()of the role, as in the colour lesson, so the hover follows the brand forever. - Reproduce card three in DevTools: set
--oops: 12pxon a paragraph and use it as a colour. Then register it with a colour type and watch the behaviour change.
Check yourself
A Sass variable and a custom property hold the same colour. Name one thing only the custom property can do.
Any of: be overridden for one element and its descendants, change while the page is open, differ between light and dark without recompiling, or be read and written from JavaScript. The Sass variable is replaced by its value at build time and no longer exists in the browser.
color: var(--brand, blue) renders black, not blue. What happened?
--brand is set, to something that is not a colour. The fallback is only for a property
that is not set at all. The declaration became invalid at computed-value time, and
color fell back to its inherited value.
Where should dark mode live in a three-tier system?
In the second tier. Roles such as --surface and --text point at different
primitives, and components keep reading the same role names.
The mental model to keep
A custom property is a message passed down the tree. Whoever sets it decides what is true for everything beneath them, and whoever reads it does not need to know who that was. Design a small vocabulary of messages, keep raw values at the top, and components become things you configure instead of things you rewrite.
Last reviewed 2026-09-19. Browser status on this page is read from the web-features dataset when the site is built, not typed.