EduFlow
Modern CSS Mastery
0%

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.

  1. 1A component with one dial
  2. 2When substitution goes wrong
  3. 3Register the property with @property
  4. 4Animating what used to jump
  5. 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.css
.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; }
HTML
<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:

CSS
@property --track-color {
  syntax: "<color>";
  inherits: true;
  initial-value: #64748b;
}

Now three things change:

  1. 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 the initial-value if nobody sets it. The bad card gets a slate grey border instead of none.
  2. Computed values are typed. getComputedStyle(card).getPropertyValue('--track-color') returns rgb(219, 39, 119), not the raw text you wrote.
  3. 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:

CSS
@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.
  • @property gives a custom property a syntax, an initial value and an inheritance flag, which makes bad values fall back safely and lets the property animate.

Further reading

Check your understanding

4 questions

Question 1 of 4

What padding does .card get?

CSS
.card {
  --space: blue;
  padding: 24px;
  padding: var(--space);
}
Question 2 of 4

You register @property --angle { syntax: '<angle>'; inherits: false; initial-value: 0deg; }. What does this give you that an unregistered --angle doesn’t?

Question 3 of 4

Where should a session card’s accent color be set so that a .track-design card and everything inside it uses pink?

Question 4 of 4

A registered property has syntax: '<color>'; inherits: true; initial-value: gray, and a card sets --accent: 12px. Its parent doesn’t set --accent. What value does the card end up with?

Practice

Code playground

Register the track color

The CMS wrote 12px into the Lightning Talks card’s --track-color, so its border and badge lose their color. Register --track-color with @property as a <color> that inherits and has an initial value of #64748b (slate grey). The Design (#db2777) and Performance (#0891b2) cards must keep their colors, and the broken card should fall back to slate grey.

<section class="schedule">
  <article class="session-card track-design" id="s1">
    <h2>Designing in oklch()</h2>
    <span class="badge">Design</span>
  </article>
  <article class="session-card track-perf" id="s2">
    <h2>The Cost of a Frame</h2>
    <span class="badge">Performance</span>
  </article>
  <article class="session-card track-cms" id="s3">
    <h2>Lightning Talks</h2>
    <span class="badge">Misc</span>
  </article>
</section>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • The Design card is pink
  • The Performance card is cyan
  • The card with a bad value falls back to slate grey
  • –track-color is registered as a color