Lesson 2
@property - Registered Custom Properties
In Lesson 1, custom properties were introduced as untyped - the browser treats them as raw token strings until substitution. This has a real consequence: you can't animate them.
The browser doesn't know if --foo holds a colour, a length, or a sentence. Without type information, it can't interpolate between two values - it can only do a hard swap at 50% of a transition.
The problem: transitioning an unregistered property
Hover both boxes - same transition CSS, different results
Unregistered: hard swap (flips at 50%)
Registered as <color>: smooth transition
/* Unregistered - browser can't interpolate, hard swap */
.no-register-box {
--bg-color: #3a86ff;
background: var(--bg-color);
transition: --bg-color 0.8s ease-in-out;
}
.no-register-box:hover { --bg-color: #e76f51; }
/* Registered - browser knows it's a <color>, smooth transition */
@property --reg-color {
syntax: "<color>";
inherits: false;
initial-value: #3a86ff;
}
.registered-box {
background: var(--reg-color);
transition: --reg-color 0.8s ease-in-out;
}
.registered-box:hover { --reg-color: #e76f51; }
Both boxes use the same pattern - set a custom property, transition it on hover. But only the registered one transitions smoothly. The unregistered one snaps.
The solution: @property
The @property at-rule lets you register a custom property with three required pieces of information:
@property --reg-color {
syntax: "<color>"; /* 1. What TYPE of value this holds */
inherits: false; /* 2. Does it cascade to children? */
initial-value: #3a86ff; /* 3. Default when not explicitly set */
}
Once registered, the browser knows --reg-color is a colour. It can interpolate between colour values, so transitions and animations work.
The three descriptors
1. syntax
Defines what values are valid. Uses CSS value definition syntax in a string:
"<color>"- any colour value"<length>"- e.g. 16px, 2rem, 100vh"<number>"- unitless number"<percentage>"- e.g. 50%"<length-percentage>"- either a length or a percentage"<integer>"- whole numbers only"<angle>"- e.g. 45deg, 0.5turn"<time>"- e.g. 300ms, 1s"<image>"- gradients, url() images"<transform-function>"- rotate(), scale(), etc."<transform-list>"- multiple transforms"<custom-ident>"- a keyword you define"*"- any value (universal, but loses interpolation)
You can combine types with | for alternatives, append # for comma-separated lists, or + for space-separated lists:
@property --flexible {
syntax: "<length> | <percentage>"; /* accepts either */
inherits: false;
initial-value: 0px;
}
@property --color-list {
syntax: "<color>#"; /* comma-separated list of colours */
inherits: true;
initial-value: red, blue;
}
@property --spacing {
syntax: "<length>+"; /* space-separated list of lengths */
inherits: false;
initial-value: 1rem 2rem;
}
2. inherits
Either true or false.
Recall from Lesson 1: unregistered custom properties always inherit. Registration gives you the choice to stop that.
--card-accent registered with inherits: false
@property --card-accent {
syntax: "<color>";
inherits: false; /* children don't get parent's value */
initial-value: #3a86ff; /* they get this instead */
}
.parent-card { --card-accent: #e76f51; border: 3px solid var(--card-accent); }
.child-card { border: 3px solid var(--card-accent); } /* gets blue (initial-value)! */
inherits: false: component-level properties that should reset per-component, not leak from parents. Think of it as creating a property that behaves like padding (doesn't inherit) rather than color (does inherit).
3. initial-value
The guaranteed default. Used when:
- The property isn't explicitly set on the element (and
inherits: false) - The property isn't set anywhere in the ancestor chain (and
inherits: true) - An invalid value is assigned - it falls back to the initial value instead of becoming broken
Must be a valid value per the syntax. Required unless syntax is "*".
Type safety: invalid values fall back gracefully
@property --safe-size {
syntax: "<length>";
inherits: false;
initial-value: 16px;
}
.box {
--safe-size: banana; /* invalid for <length> */
padding: var(--safe-size); /* gets 16px (initial-value), not broken */
}
Compare this to unregistered properties: if you set an unregistered --size: banana and use it in padding: var(--size), the padding declaration becomes invalid at computed-value time and you get the inherited value (or the property's own initial value) - which is often 0. Much harder to debug.
The big unlock: animating custom properties
This is the killer feature. With registration, you can transition or animate things that were previously impossible in pure CSS:
Transitioning a hue value
Hover to transition --hue from 220 to 340 (smooth interpolation through the colour space)
@property --hue {
syntax: "<number>";
inherits: false;
initial-value: 220;
}
.hue-box {
background: oklch(0.7 0.15 var(--hue));
transition: --hue 0.8s ease-in-out;
}
.hue-box:hover {
--hue: 340;
}
Animating a gradient stop position
You can't transition a gradient directly - the browser sees the whole linear-gradient(...) as a single value. But if you pull out the stop position into a registered property, you can animate just that number:
Hover to animate the gradient stop from 30% to 80%
@property --stop {
syntax: "<percentage>";
inherits: false;
initial-value: 30%;
}
.gradient-stop-box {
background: linear-gradient(to right, blue var(--stop), transparent var(--stop));
transition: --stop 0.6s ease-in-out;
}
.gradient-stop-box:hover {
--stop: 80%;
}
Browser support
@property is supported in Chrome/Edge 85+, Safari 16.4+, and Firefox 128+. As of mid-2025, it's safe for production with a reasonable fallback (the property just acts unregistered in older browsers - no animation, but no breakage).
Quiz
Question 1
Why can't you transition an unregistered custom property?
Question 2
What are the three required descriptors in an @property rule?
Question 3
You register --size with syntax "<length>" and initial-value 16px. Someone sets --size: red. What does var(--size) resolve to?
Question 4
You want to animate a gradient's colour stop from 20% to 80%. What syntax do you register the property with?
Question 5
A property is registered with inherits: false. A parent sets it to orange. A child reads it with var(). What does the child get?