← blog
CSSJuly 8, 2026 · 5 min read

You can't transition a CSS variable. @property says otherwise.

CSS custom properties are string substitution — the browser doesn't know a number from a color. Register one with @property and it gains a real type, an initial value, and the ability to animate.

Parsa Jiravand · Frontend engineer · building bestpractic
You can't transition a CSS variable. @property says otherwise.

You've probably written something like this:

CSS
1
2
3
4
5
6
7
8
9
.button { --hue: 220; background: hsl(var(--hue) 70% 50%); transition: --hue 0.3s ease; } .button:hover { --hue: 340; }

You expected a smooth color shift. You got a snap cut.

That's not a browser bug. It's the result of one architectural decision baked into custom properties from the start: CSS variables are text substitution, and the browser has no idea what type they hold.

When you write --hue: 220, the browser stores the string 220. When it sees hsl(var(--hue) 70% 50%), it does a find-and-replace: swap the token for the string, then parse the result as a color. No type information, no range checking. Just substitution.

That means the browser can't interpolate between --hue: 220 and --hue: 340 during a transition, because from its perspective, you're asking it to animate between two arbitrary strings. What does "halfway between 220 and 340" mean when both are just text? The browser won't guess. It snaps.

This is also why calc(var(--scale) + 1) works fine — the substituted string parses as a valid calculation — but transitions involving custom properties silently fail without any error in the console to tell you why.

@property is a CSS at-rule that registers a custom property in the browser's type system. You declare three things: what type of value it holds, what its initial value is, and whether it inherits down the tree.

CSS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@property --hue { syntax: "<number>"; inherits: false; initial-value: 220; } .button { background: hsl(var(--hue) 70% 50%); transition: --hue 0.3s ease; } .button:hover { --hue: 340; }

Now the browser knows --hue is a <number>. It can interpolate from 220 to 340 over 300ms. The color eases.

The change is one @property block at the top of your stylesheet. The rest of the CSS is identical to the version that was snapping.

Runs right in your browser — poke at it and watch the concept react live.

syntax accepts CSS value type identifiers — the same vocabulary you'd find in any property's value definition:

CSS
1
2
3
4
5
6
syntax: "<number>"; /* integer or float */ syntax: "<length>"; /* 10px, 2rem, etc. */ syntax: "<color>"; /* #abc, hsl(), oklch() */ syntax: "<angle>"; /* 45deg, 0.5turn */ syntax: "<percentage>"; /* 0% to 100% */ syntax: "*"; /* any value — same as unregistered */

syntax: "*" is the escape hatch. Use it when you want initial-value or inheritance control without restricting what the property can hold.

The use case that put @property on most developers' radar was animating the angle of a conic gradient — something that was impossible before, because background itself can't be transitioned (the value is too complex for the browser to interpolate). With a registered <angle> property driving it:

CSS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@property --spin { syntax: "<angle>"; inherits: false; initial-value: 0deg; } .loader { background: conic-gradient( from var(--spin), oklch(70% 0.2 220), oklch(60% 0.3 300) ); animation: rotate 1.5s linear infinite; } @keyframes rotate { to { --spin: 360deg; } }

The browser knows --spin is an <angle>, so @keyframes interpolates through the turn. The gradient rotates smoothly. No JavaScript, no wrapper element with transform: rotate() — just a typed variable doing the work a variable should do.

Before @property, this required either JavaScript to drive the animation frame by frame, or a rotate() hack on an absolutely-positioned pseudo-element. Neither felt right because neither was right.

Animated gradients are the headline, but initial-value is what earns @property a permanent place in how I structure stylesheets.

An unregistered custom property that's never declared returns an empty string. That empty string gets substituted wherever you use the property, which silently invalids the declaration. A missing --spacing-md isn't 0 — it's "", and padding: "" is quietly thrown away by the parser. You won't get a console error. The layout just breaks in ways that take time to trace.

Register the property with a valid initial-value and the browser has a real fallback:

CSS
1
2
3
4
5
6
7
8
9
@property --spacing-md { syntax: "<length>"; inherits: true; initial-value: 1rem; } .card { padding: var(--spacing-md); /* 1rem if nothing sets it */ }

This matters most for design token systems where tokens flow through a large tree and you can't always guarantee the declaring element is an ancestor of the consuming element.

@property shipped in Chrome 85 in late 2020, Firefox 128 in mid-2024, and Safari 16.4 in early 2023. It's Baseline 2024 — meaning cross-browser in any browser released in the last couple of years.

Browsers that don't support @property ignore the at-rule entirely (it's an unknown rule they skip over), and your custom property falls back to normal unregistered behavior. The property still works. Transitions still run — they just snap instead of easing. That's the progressive enhancement story: declare @property, get animated values in modern browsers, get the same static result in older ones.

No @supports guard required unless you're writing critical UI that would break on a snap cut.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Go back through your CSS and find every place you reached for JavaScript to drive an animation that was ultimately about changing a CSS value — a color shift, a progress bar, a spinning gradient, a morphing border radius. Ask: is this actually a typed variable animation in disguise?

The custom property was always there. The type is what was missing.

What animation have you been running in JavaScript that could be a registered CSS variable and a @keyframes block? Genuinely curious — because nine times out of ten when I've asked this question, the JavaScript disappears.


Thanks for reading! Let's stay connected:

Originally published on dev.to