Tags: web-dev concept

Custom Properties

Date: 2026-09-27


--name: value is a real CSS property. It cascades, it inherits, and it’s resolved per element at runtime. A Sass variable is replaced once at build time. So a custom property can differ inside a dark section, a card or a hover state, and a Sass variable can’t.


Custom properties (informally “CSS variables”) are author-defined properties, named with a -- prefix and read with var(). Because they’re properties, not constants, they follow the whole cascade and inherit down the tree — The Cascade and Specificity. The runtime and theming side, and why changing one on the root is cheap, is in The CSSOM.

vs build-time variables

SASS                                   CUSTOM PROPERTY

$accent: blue;                          :root { --accent: blue; }
.btn { color: $accent; }                .btn  { color: var(--accent); }
.dark .btn { color: $accent; }          .dark { --accent: skyblue; }
                                        
compiled output:                        output: unchanged
.btn       { color: blue; }             .btn inside .dark → skyblue
.dark .btn { color: blue; }  ← same     .btn elsewhere    → blue
                                        ← decided per element, at runtime

The Sass variable is gone before the browser sees the file, so it can’t respond to where an element sits in the DOM. The custom property is resolved per element, by inheritance. That’s the entire basis of token-driven theming — Theming, Design Tokens.

Scoping is the feature

Set a property on any element and it applies to that subtree. That makes it a component’s styling API:

.button {
  /* private defaults, overridable from outside without touching internals */
  background: var(--button-bg, var(--colour-action));
  padding: var(--button-pad, 0.75rem 1.25rem);
}
 
.hero .button { --button-bg: white; }   /* a context override, no specificity fight */
.button:hover { --button-bg: var(--colour-action-hover); }

The consumer sets --button-bg and never needs to know which internal property reads it — the same idea as a web component’s styling surface — Web Components, Component API Design.

The rule that surprises: invalid at computed-value time

A custom property holds an unparsed string of tokens, so the browser can’t check color: var(--x) when it parses the stylesheet. It only finds out when it substitutes the value.

.el { color: red; }
.el { color: var(--size); }   /* --size: 20px — not a colour */
expected:  second declaration is invalid → fall back to red
actual:    color becomes UNSET → inherited colour (often black)
           red was already discarded by the cascade

A bad var() doesn’t fall back to the previous declaration. The cascade has already picked the declaration that contains the var(), and when substitution fails the property resets to its inherited or initial value. The fallback argument, var(--x, red), only covers the property being undefined, not being the wrong type.

Registering a property

@property gives a custom property a type, an inheritance setting and an initial value:

@property --progress {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

That does two things an unregistered property can’t: it can be transitioned or animated, since the browser knows how to interpolate a percentage but not a token string, and an invalid value falls back to initial-value rather than unset [CHECK: @property support in the browsers that matter for the project].

Limits

  • Not in media or container query conditions. @media (min-width: var(--bp)) doesn’t work — breakpoints stay build-time constants
  • Not in selectors or property names. Values only
  • Inheritance has a cost at scale. Changing a property on :root restyles every element that inherits it. Usually fine; worth knowing in a very large DOM animating a root-level property per frame — Reflow and Repaint
  • Typos fail silently. var(--colour-acton) is just undefined. Linting against the token list catches it — Linting

Where it connects

  • Cascade Layers — custom properties cascade like anything else, so layer order applies to them too
  • Fluid Type and Space — the usual home for clamp() values is a custom property per step