#Custom properties (CSS variables)
AddedA custom property is any property whose name starts with two dashes, like --accent. The browser doesn't know what it means, so it doesn't check the value: it stores it as written and lets you pull it into real properties with var(--accent). Unlike almost every other property, custom properties inherit by default, so a value set on :root is visible everywhere, and a value set on one element is visible to all of its descendants.
The mental model that matters in interviews: these are not Sass variables. A Sass variable is replaced by its value when the stylesheet is compiled, and after that it no longer exists. A custom property is a live property on every element in the DOM. It goes through the cascade like color does, can be overridden for one subtree, changed by a media query or a class, and read or written by JavaScript at runtime, and every var() that depends on it updates.
var() is resolved at computed-value time, per element: the browser looks up the element's own value of --accent (inherited from the nearest ancestor that set it), pastes it into the declaration, and only then parses the result. That one fact explains the features (overriding per component) and the surprising failures (see the quiz below).
:root {
--accent: #2563eb;
--space: 8px;
}
.card {
width: 200px;
margin-bottom: 8px;
padding: calc(var(--space) * 2);
border: 2px solid var(--accent);
color: var(--accent);
}
.card.danger {
--accent: #dc2626;
}
.badge {
background: var(--accent);
color: #fff;
padding: 0 var(--space);
}
.note {
width: var(--note-width, 150px);
background: #f1f5f9;
}<div class="card">Default card <span class="badge">new</span></div>
<div class="card danger">Danger card <span class="badge">!</span></div>
<div class="note">fallback width</div>:root(the<html>element) sets--accent: #2563eband--space: 8px. Because custom properties inherit, every element on the page now has those two values unless something closer overrides them.- The first card resolves
color: var(--accent)torgb(37, 99, 235)andpadding: calc(var(--space) * 2)to16px. The substitution happens per element, at computed-value time. .card.dangersets its own--accent: #dc2626. It changes nothing about.card's declarations: the samecolor: var(--accent)now finds a different value on this element and computes torgb(220, 38, 38).- The badges show inheritance at work: each
.badgehas no--accentof its own, so it inherits from its card. The first badge's background isrgb(37, 99, 235), the second's isrgb(220, 38, 38). This is how one component can be re-themed by setting a single property on its root. .noteusesvar(--note-width, 150px). Nothing defines--note-width, so the fallback after the comma is used:150px.- Reading
--accentback withgetComputedStylegives"#2563eb", notrgb(...): an unregistered custom property's computed value is the text you wrote (trimmed), because the browser doesn't know it's a colour.color, a real property, computes torgb(37, 99, 235). - The test then sets
--spaceto12pxon:rootfrom JavaScript (the code below): the card's padding becomes24pxand the badge's side padding12pxwith no other change, and removing it brings back16px. That live update is what a preprocessor variable can't do.
The JavaScript side is just the normal style APIs. The last lines of the demo's test run exactly this against the cards above:
const root = document.documentElement;
// Read: the computed value, inherited or not
getComputedStyle(root).getPropertyValue("--accent"); // "#2563eb"
// Write: inline on an element (here :root), so it wins over the stylesheet
root.style.setProperty("--space", "12px"); // every var(--space) updates
root.style.removeProperty("--space"); // back to the stylesheet value:root { --size: 20px; }
.parent { color: green; }
.child { color: blue; color: var(--size); }<div class="parent"><p class="child">What colour am I?</p></div>What colour is the paragraph's text?
Show answer
Green, not blue. var(--size) produces 20px, which isn't a colour, and by then it's too late to go back to color: blue.
:root { --size: 20px; }
.parent { color: green; }
.child { color: blue; color: var(--size); }
.boxed { width: 120px; width: var(--size-word); --size-word: wide; height: 10px; background: #fde68a; }<div class="parent"><p class="child">What colour am I?</p></div>
<div class="boxed"></div>- At parse time the browser can't know what
var(--size)will become, so it acceptscolor: var(--size)as valid. It's the later of the twocolordeclarations, so the cascade picks it and throwscolor: blueaway. - At computed-value time the browser substitutes
20px, getscolor: 20px, and finds it invalid. This is called invalid at computed-value time. - The rule for that case: the property behaves as if it were set to
unset.coloris an inherited property, sounsetmeansinherit, and the paragraph takes its parent's colour: measuredrgb(0, 128, 0), green. - For a property that doesn't inherit,
unsetmeansinitial. The yellow.boxedbar shows it:width: var(--size-word)with--size-word: wideloses the earlierwidth: 120pxand computes toauto, which here is the full616pxof the frame. - The fix is to keep custom properties to one type, or to register the property with
@propertyand asyntax(below), so an invalid value falls back to the registered initial value instead.
Registering a property with @property
@property tells the browser what type a custom property has, whether it inherits and what its initial value is. Once registered, the value is parsed and computed like a real property: lengths are resolved, angles converted, and an invalid value is rejected in favour of the initial value. Registered properties are Baseline 2024 (newly available since July 2024, when Firefox 128 shipped them).
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.dial {
--angle: 0.25turn;
width: 60px;
height: 60px;
border-radius: 50%;
background: conic-gradient(#2563eb var(--angle), #e2e8f0 0);
}
.dial.bad { --angle: red; }
.spin { transition: --angle 1000ms linear; }
.spin.on { --angle: 180deg; }
.plain { --size: 10px; transition: --size 1000ms linear; }
@property --nope { syntax: "<length>"; initial-value: 0px; }
.dial { --nope: 1em; }
.plain.on { --size: 90px; }<div class="dial"><span class="inner">child</span></div>
<div class="dial bad"></div>
<div class="dial spin"></div>
<div class="plain"></div>@property --angleregisters the name withsyntax: "<angle>",inherits: falseandinitial-value: 0deg. All three descriptors are required for the rule to be valid (initial-valuemay only be left out when the syntax is"*"). The test also registers a--nopewithoutinherits: that rule is ignored, and--nope: 1emstays plain unregistered text,"1em"..dialsets--angle: 0.25turn. Because the browser now knows it's an angle, it computes it likerotatewould:getComputedStylereturns90deg, not the text0.25turn..dial.badsets--angle: red. For a registered property that's simply invalid, so the declaration is ignored and the value is the initial0deg. Compare the quiz above, where the unregistered version broke the property that used it.inherits: falsemeans the.innerspan inside the first dial does not get90deg; it has the initial0deg.- The big win is animation.
.spintransitions--angle; after adding.onthere is a realCSSTransitionfor--angle, and seeking it to 500 ms of 1000 ms gives135deg, half way from90degto180deg. The conic gradient that uses it animates with it, which isn't possible by transitioningbackgrounditself (gradients don't interpolate). - The unregistered
--sizeon.plaingets no transition at all (0animations) and jumps straight to90px: the browser can't interpolate a value it only knows as text.
The same registration is available from JavaScript as CSS.registerProperty({ name: "--angle", syntax: "<angle>", inherits: false, initialValue: "0deg" }).