DEV Community

Cover image for You can't transition a CSS variable. @property says otherwise.
Parsa Jiravand
Parsa Jiravand

Posted on

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

Enabling interpolation via explicit types

You've probably written something like this:

.button {
  --hue: 220;
  background: hsl(var(--hue) 70% 50%);
  transition: --hue 0.3s ease;
}

.button:hover {
  --hue: 340;
}
Enter fullscreen mode Exit fullscreen mode

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.

What CSS custom properties actually are

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 gives a variable a type

@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.

@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;
}
Enter fullscreen mode Exit fullscreen mode

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.

🎮 Try it yourself

▶️ Open the interactive playground →

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

The types you'll actually reach for

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

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 */
Enter fullscreen mode Exit fullscreen mode

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

The gradient animation you gave up on

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:

@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; }
}
Enter fullscreen mode Exit fullscreen mode

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.

The hidden benefit: initial-value

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:

@property --spacing-md {
  syntax: "<length>";
  inherits: true;
  initial-value: 1rem;
}

.card {
  padding: var(--spacing-md);  /* 1rem if nothing sets it */
}
Enter fullscreen mode Exit fullscreen mode

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.

Browser support, honestly

@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.

What just became possible

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:

Top comments (6)

Collapse
 
nazar-boyko profile image
Nazar Boyko

Everyone reaches for the gradient-angle trick, but the initial-value point you slipped in near the end is the one I'd have built the whole article around. A missing variable resolving to an empty string and quietly killing the declaration is one of those bugs that eats an afternoon, because nothing errors, it just doesn't apply. Giving a token a real fallback the browser actually honors fixes a whole class of "why did my padding vanish" mysteries that have nothing to do with animation. Nice that the same at-rule buys you both.

Collapse
 
parsajiravand profile image
Parsa Jiravand

That's a great observation, and I think you're right. The animation examples tend to grab attention, but initial-value arguably solves a more common day-to-day problem.

I've definitely run into those "everything looks fine, but this declaration just isn't applying" situations where an unresolved custom property silently invalidates the whole declaration. Since there's no obvious error, tracking it down can take much longer than it should.

One of the things I like most about @property is that it improves both the developer experience and the animation story. You get typed values for interpolation, but you also get predictable defaults through initial-value, which makes components much more resilient when a variable isn't defined upstream.

Thanks for pointing that out—I may even expand that section in a future revision because it's an easy benefit to overlook.

Collapse
 
nazar-boyko profile image
Nazar Boyko

Thanks for sharing!

Collapse
 
arvavit profile image
Vadym Arnaut

Hit the identical trap animating a hue on a status pill — transition: --hue .3s just snapped, no console warning why. @property fixes interpolation but browser support gap ends up mattering in practice once you ship it.

Collapse
 
frank_signorini profile image
Frank

I've had issues with transitioning CSS variables before, but using @property seems like a game changer - how do you handle browser compatibility with this approach? I'd love to swap ideas on this.

Collapse
 
parsajiravand profile image
Parsa Jiravand

You're absolutely right—before @property, CSS custom properties were treated as untyped values, so transitions were either impossible or inconsistent. Registering them with @property makes the browser understand the value type, which is what enables smooth interpolation.

For browser compatibility, I usually treat it as a progressive enhancement. Modern Chromium-based browsers and Safari support it well, while older browsers that don't support @property simply ignore the registration. The component still works; it just falls back to non-animated state changes instead of breaking.

If I need to support older browsers with animated transitions, I'd typically animate the underlying CSS property directly or use a small JavaScript fallback rather than relying solely on custom property interpolation.

I'd definitely be interested to hear about the issues you ran into—were they mostly related to unsupported browsers or some edge cases with specific property types?