CSS @property: Typed Custom Properties That Actually Animate
Typed custom properties make gradients and other complex values animatable, and give design tokens an actual type.
I spent an afternoon trying to animate a gradient progress bar, wondering why the transition did nothing. The answer is that custom properties are stored as strings. The browser sees --stop: 20% and --stop: 80% as two unrelated pieces of text, so it can't work out the frames between them and the value snaps.
@property fixes that. It tells the browser what kind of value a property holds, which makes it animatable and gives the cascade a fallback for invalid values.
What Is @property?
@property registers a custom property with a type, an initial value, and an inheritance rule.
@property --hue {
syntax: "<number>";
initial-value: 0;
inherits: false;
}Once registered, the browser knows --hue is a number and can interpolate it. It can also reject values that don't match.
Animating Gradients (The Killer Use Case)
This doesn't animate. The gradient jumps from the start value to the end value:
/* No animation, the value snaps */
.box {
--color-stop: 0%;
background: linear-gradient(to right, red var(--color-stop), blue);
transition: --color-stop 1s;
}
.box:hover {
--color-stop: 100%;
}Register it as a <percentage> and the same transition runs smoothly:
/* Smooth animation */
@property --color-stop {
syntax: "<percentage>";
initial-value: 0%;
inherits: false;
}
.box {
background: linear-gradient(to right, red var(--color-stop), blue);
transition: --color-stop 1s ease;
}
.box:hover {
--color-stop: 100%;
}Supported Syntax Types
The syntax descriptor accepts these:
| Syntax | Example value |
|---|---|
<number> | 42 |
<integer> | 3 |
<length> | 16px, 1rem |
<percentage> | 50% |
<color> | #ff0, oklch() |
<angle> | 90deg |
<length-percentage> | calc(50% + 8px) |
<transform-function> | rotate(45deg) |
* | Any value |
The wildcard brings back the string behaviour.
Typed Design Tokens
The other use is catching mistakes. A plain variable is a string, so nothing stops this until it looks wrong:
/* Before: no validation */
:root { --spacing-md: 16px; }Registered, the property has a declared type and a fallback:
@property --spacing-md {
syntax: "<length>";
initial-value: 16px;
inherits: true;
}If someone writes --spacing-md: red, the declaration is invalid and the browser uses initial-value. I've only bothered for tokens that get animated or used in calc(), since registering everything is a lot of boilerplate.
Real Example: Animated Hue Rotation
@property --hue {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
@keyframes spin-hue {
to { --hue: 360; }
}
.badge {
background: oklch(70% 0.2 var(--hue));
animation: spin-hue 4s linear infinite;
}The badge cycles through the hue wheel. Unregistered, it would jump from 0 to 360, the same colour, so it would appear to do nothing.
Browser Support
@property is supported in all modern browsers as of 2025 (Chrome 85+, Edge 85+, Firefox 128+, Safari 16.4+), so there's no need to guard it.
Small at-rule, narrow uses, but animated gradients come up often enough that I keep it.