Section 1 · The Cascade, for Real
Custom Properties and @property
18 min4 min readCode playground
Lesson at a glance
Use custom properties as a component’s public API, understand when var() substitution fails, and register typed properties with @property so they validate and animate.
- 1A component with one dial
- 2When substitution goes wrong
- 3Register the property with @property
- 4Animating what used to jump
- 5Your turn
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Waypoint’s session cards come in four tracks (Design, Performance, Accessibility and Platform), and each track has its own color for the left border, the badge and the title hover state. The first version had a rule per track per element: twelve rules, and a thirteenth set every time marketing added a track.
Custom properties turn that into one rule per track and a card that reads its color from a single dial.
A component with one dial
.session-card {
border-inline-start: 6px solid var(--track-color, #64748b);
}
.session-card .badge {
background: var(--track-color, #64748b);
color: white;
}
.track-design { --track-color: #db2777; }
.track-perf { --track-color: #0891b2; }<article class="session-card track-design">…</article>Two things make this work. First, custom properties inherit: setting --track-color on the card makes it visible to the badge inside. Second, var() is resolved at the element that uses it, at computed-value time, so each card substitutes its own value. The second argument to var() is the fallback, used only when the property isn’t set at all.
Where you set a custom property matters. Global design tokens (brand colors, the spacing scale, font stacks) belong on :root, so every element inherits them. Component tokens such as --track-color belong on the component, so they stay local and one card can differ from its neighbour. Changing a property on :root from JavaScript forces the browser to recompute styles for the whole tree that uses it, so for something that changes often, like a pointer position, set it on the smallest element that needs it.
Think of --track-color as the card’s public API. Anyone can theme a card from outside, in a CMS template or a one-off campaign page, without knowing its internals.
When substitution goes wrong
Unregistered custom properties are just strings of tokens. The browser doesn’t know --track-color is supposed to be a color, so --track-color: 12px is a perfectly valid declaration. The problem appears later, when var() drops 12px into border-inline-start’s color.
That’s how Waypoint shipped a card with no track border at all and a see-through badge: the CMS wrote 12px into the color field, and nothing complained.
Register the property with @property
@property tells the browser what a custom property is:
@property --track-color {
syntax: "<color>";
inherits: true;
initial-value: #64748b;
}Now three things change:
- Validation. A value that isn’t a
<color>makes the property fall back safely: it behaves as unset, so an inherited property takes the parent’s value, which ends at theinitial-valueif nobody sets it. The bad card gets a slate grey border instead of none. - Computed values are typed.
getComputedStyle(card).getPropertyValue('--track-color')returnsrgb(219, 39, 119), not the raw text you wrote. - Interpolation. The browser knows how to blend between two colors, so the property can transition.
The syntax descriptor accepts types such as <color>, <length>, <length-percentage>, <number>, <percentage>, <angle>, <time> and <integer>, plus "*" for anything. initial-value is required unless the syntax is "*", and it must be computationally independent: 10px is fine, 2em isn’t.
Animating what used to jump
Here’s a hover effect that’s only possible with registration. The card’s top edge is a gradient whose angle is a custom property:
@property --sheen-angle {
syntax: "<angle>";
inherits: false;
initial-value: 90deg;
}
.session-card {
background: linear-gradient(var(--sheen-angle), #f5f3ff, white 60%);
transition: --sheen-angle 400ms ease;
}
.session-card:hover {
--sheen-angle: 160deg;
}Without @property, the browser can’t interpolate a token string, so the gradient snaps from one angle to the other. With it, the angle animates smoothly. inherits: false is right here: the angle belongs to this card’s background, and children shouldn’t receive it.
Your turn
The exercise reproduces the CMS bug: one card’s --track-color is 12px. Register the property so bad values fall back to slate grey, and check that real track colors still come through. The next lesson keeps working on these cards with native nesting and :has(), which lets a card react to what’s inside it.
Key takeaways
- Custom properties inherit by default and are substituted where
var()is used, so setting one on a parent re-themes every descendant that reads it. - A
var()that produces an invalid value makes the property invalid at computed-value time; the browser uses the inherited or initial value, not your earlier declaration. - An unregistered custom property is an untyped string of tokens, so the browser can’t validate it or interpolate it in a transition.
@propertygives a custom property a syntax, an initial value and an inheritance flag, which makes bad values fall back safely and lets the property animate.