
The CSS @property Rule: Typed and Animatable Variables
Try this experiment. Put a gradient on a button using a custom property for the angle, then transition that property on hover:
.button {
--angle: 0deg;
background: linear-gradient(var(--angle), #38bdf8, #818cf8);
transition: --angle 600ms ease;
}
.button:hover {
--angle: 180deg;
}
Nothing animates. The gradient jumps from one angle to the other instantly. That's because, to the browser, --angle isn't an angle at all. A regular custom property is just a string of tokens. The browser has no idea that 0deg and 180deg are two points on a scale with values in between, so it can't interpolate between them.
The @property rule fixes that. It lets you register a custom property with a type, an initial value, and an inheritance behavior. Once the browser knows --angle is an <angle>, it can animate it, validate it, and treat it like a real CSS property. This guide covers the syntax, the types you can use, practical animation patterns, and the performance and compatibility details worth knowing.
The Syntax
An @property rule has three descriptors:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
- syntax describes what kind of value the property accepts, written as a string.
- inherits says whether child elements inherit the value, like
colordoes, or not, likeborderdoesn't. - initial-value is the value the property has when nothing else sets it.
With that one rule in place, the button example above works. Add it to the stylesheet, and hovering smoothly rotates the gradient through every angle between 0 and 180 degrees.
All three descriptors matter. syntax and inherits are required. initial-value is required unless the syntax is the universal "*", and if it's missing or invalid for the given syntax, the whole rule is ignored.
Why Types Matter
Registering a type changes how the browser handles a custom property in three important ways.
1. It Can Be Interpolated
Once the browser knows a property is a length, color, angle, number, or percentage, it knows how to compute intermediate values. That means transitions and animations work, including inside @keyframes.
2. It's Validated
An unregistered custom property accepts anything, even nonsense:
.box {
--size: banana;
width: var(--size); /* invalid at computed-value time */
}
When a registered property gets a value that doesn't match its syntax, the value is rejected when styles are computed, and the property falls back to its inherited value (if it inherits) or its initial-value (if it doesn't). It doesn't fall back to an earlier valid declaration in the cascade. With the registration below, --size: banana is rejected, and --size computes to 100px:
@property --size {
syntax: "<length>";
inherits: false;
initial-value: 100px;
}
That makes components more robust. A bad value passed by a consumer doesn't break the layout; it falls back to a sensible default.
3. It Has a Computed Value
A registered property is computed like a real property. A <length> given in em or rem is converted to an absolute length on the element where it's set, and a <color> is resolved to a color. That affects inheritance: child elements inherit the computed value, not the original tokens. For most uses you won't notice, but it matters if you expected an em value to be re-evaluated against each child's font size.
Available Types
The syntax descriptor accepts these data type names, each wrapped in angle brackets inside the string:
| Syntax | Example values |
|---|---|
"<length>" | 10px, 2rem, 50vw |
"<number>" | 0, 1.5, -3 |
"<percentage>" | 50% |
"<length-percentage>" | 10px, 50%, calc(50% - 1rem) |
"<color>" | red, #38bdf8, oklch(70% 0.1 250) |
"<angle>" | 45deg, 0.5turn |
"<time>" | 300ms, 2s |
"<integer>" | 1, 42 |
"<resolution>" | 2dppx |
"<image>" | url(bg.png), linear-gradient(...) |
"<url>" | url(icon.svg) |
"<transform-function>" | rotate(45deg) |
"<transform-list>" | rotate(45deg) scale(1.2) |
"<custom-ident>" | compact, expanded |
"*" | anything (like an unregistered property) |
You can also combine them:
"<length> | <percentage>"accepts either type."<length>+"accepts a space-separated list of lengths."<color>#"accepts a comma-separated list of colors."small | medium | large"accepts only those exact keywords.
Not every type can be interpolated. Lengths, numbers, percentages, colors, angles, times, integers, and transforms animate smoothly. Images, URLs, and custom identifiers can't be interpolated and flip from one value to the next, the same way display does.
Practical Patterns
Animated Gradient Borders
A popular effect is a border that sweeps around a card. With a registered angle, it's pure CSS:
<div class="glow-card">
<h3>Pro plan</h3>
<p>Everything in Starter, plus team seats and priority support.</p>
</div>
@property --border-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0turn;
}
.glow-card {
padding: 2rem;
border: 3px solid transparent;
border-radius: 16px;
background:
linear-gradient(#111827, #111827) padding-box,
conic-gradient(from var(--border-angle), #38bdf8, #818cf8, #f472b6, #38bdf8)
border-box;
color: #f8fafc;
animation: spin-border 4s linear infinite;
}
@keyframes spin-border {
to {
--border-angle: 1turn;
}
}
@media (prefers-reduced-motion: reduce) {
.glow-card {
animation: none;
}
}
The card's background has two layers: a solid fill clipped to the padding box, and a conic gradient clipped to the border box. Only the border area shows the gradient. Animating --border-angle rotates the gradient's starting point, so the colors travel around the edge.
Smooth Color Transitions in Gradients
Gradients can't be transitioned directly, because background-image isn't interpolated between different gradients. But the colors inside a gradient can be, if they're registered:
@property --stop-1 {
syntax: "<color>";
inherits: false;
initial-value: #38bdf8;
}
@property --stop-2 {
syntax: "<color>";
inherits: false;
initial-value: #818cf8;
}
.hero-button {
background: linear-gradient(135deg, var(--stop-1), var(--stop-2));
transition:
--stop-1 400ms ease,
--stop-2 400ms ease;
}
.hero-button:hover {
--stop-1: #f472b6;
--stop-2: #fbbf24;
}
Hovering now blends each color stop smoothly into the new one. The gradient itself is recomputed every frame from the animated colors.
A Progress Ring Driven by a Number
A registered <percentage> makes a conic-gradient progress ring that animates when its value changes:
<div
class="ring"
style="--progress: 72%"
role="img"
aria-label="72% complete"
></div>
@property --progress {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.ring {
width: 120px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(closest-side, #111827 78%, transparent 80%),
conic-gradient(#34d399 var(--progress), #334155 0);
transition: --progress 800ms ease-out;
}
When JavaScript updates --progress, the ring animates to the new value instead of jumping:
const ring = document.querySelector(".ring");
function setProgress(value) {
ring.style.setProperty("--progress", `${value}%`);
ring.setAttribute("aria-label", `${value}% complete`);
}
For the ring to animate on first load, start the inline value at 0% and set the real value after the first frame, or use a @keyframes animation from 0%.
Counting Numbers with an Integer
Registered integers can drive CSS counters, which gives you an animated number without JavaScript:
<p class="stat"><span class="stat__value"></span> happy customers</p>
@property --count {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
.stat__value {
animation: count-up 2s ease-out forwards;
counter-reset: count var(--count);
}
.stat__value::after {
content: counter(count);
}
@keyframes count-up {
to {
--count: 1250;
}
}
The integer animates from 0 to 1250, and the counter displays each intermediate value. Because the number lives in a pseudo-element's content, screen reader support for generated content varies, so for important figures, put the final number in the HTML and use this as a visual enhancement only.
Registering Properties in JavaScript
You can also register properties with CSS.registerProperty(). It takes the same information, with initialValue in camelCase:
if ("registerProperty" in CSS) {
CSS.registerProperty({
name: "--card-lift",
syntax: "<length>",
inherits: false,
initialValue: "0px",
});
}
This is handy when properties are defined by a component library at runtime. A few differences from @property:
- Registering the same name twice in JavaScript throws an error.
- A JavaScript registration takes precedence over an
@propertyrule for the same name. - If multiple
@propertyrules define the same name, the last one in the stylesheet wins, like other cascaded rules.
Registration is global to the document. You can't register a property differently for different parts of the page.
Performance: Use inherits: false When You Can
When a registered property has inherits: true and its value changes on an element, the browser has to recompute that value for every descendant. On a large subtree, animating an inherited property every frame can be expensive.
With inherits: false, a change only affects the element itself. For animation-driving properties like angles, positions, and progress values, inherits: false is usually what you want. Set it to true only for values that are genuinely meant to flow down the tree, like theme tokens.
Also be aware that animating a custom property can't run on the compositor thread the way transform and opacity animations can. Every frame recalculates styles for the element and anything that uses the variable. For simple effects on a few elements, that's fine. For heavy use, like animating hundreds of elements, prefer animating transform or opacity directly.
Common Pitfalls
Using relative units in initial-value. The initial-value must be computationally independent, meaning it can be resolved without context. 10px is fine; 1em isn't, because it depends on the element's font size. An invalid initial-value makes the entire rule invalid.
Forgetting inherits. Both syntax and inherits are required. A rule missing inherits is ignored.
Registering too broadly. Using syntax: "*" gives you none of the benefits: no validation and no interpolation. Pick the narrowest type that fits.
Expecting @property to scope to a component. It doesn't. A registration applies to the whole document, so choose names that won't clash with other code, such as a component prefix like --card-angle.
Browser Support and Fallbacks
@property is supported in current versions of Chrome, Edge, Safari, and Firefox, with Firefox being the most recent to add it. In a browser without support, the rule is ignored and the custom property behaves like a normal unregistered variable: it still works as a value, but it won't animate and won't be validated.
That makes it a natural progressive enhancement. Design so that the static state looks right without the animation. The gradient border example, for instance, still shows a colorful border in older browsers; it just doesn't spin. If you need to detect support in CSS, you can test a behavior that only works when registration is available, but in most cases the graceful fallback is enough.
In JavaScript, check for the API before calling it:
const supportsRegisterProperty = "registerProperty" in CSS;
Conclusion
@property turns custom properties from untyped strings into real, typed CSS values. That unlocks things that used to require JavaScript or were impossible: transitioning gradient angles and colors, animating progress rings, counting numbers with counters, and validating component inputs with safe defaults.
Register the properties you want to animate with the narrowest possible syntax, set inherits: false unless inheritance is the point, and use absolute values for initial-value. With those rules in mind, @property becomes one of the most useful tools for motion and component design in modern CSS, and it pairs naturally with a token-based theming system like the one in Theming Websites with CSS Custom Properties.


