CSS @property Tutorial: 3 Patterns That Animate What CSS Couldn't

2026-05-25 · Nico Brandt

You tried to animate a CSS variable last week. You wrote the transition, you changed the value, and the browser snapped between states like it was 2008. No interpolation, no smoothness, no clue why.

Here’s the reason nobody told you: plain custom properties are strings as far as the browser is concerned. It can’t interpolate a string any more than it can fade between “hello” and “world.” The fix isn’t more transition tuning — it’s telling the browser what kind of value the variable actually holds. That’s what this CSS @property tutorial is about: how a one-line type declaration unlocks three patterns that were genuinely impossible in pure CSS until recently. Other recent CSS animation features like @starting-style follow the same pattern. Type-safety and animation aren’t two features. They’re the same feature.

What @property Actually Does (in 40 Seconds)

@property lets you declare a type for a CSS custom property. Once the browser knows a variable is an angle, color, or length, it can smoothly animate between values — something impossible with untyped CSS variables.

That’s the whole pitch. The API is three descriptors:

@property --hue {
  syntax: '<number>';
  initial-value: 220;
  inherits: false;
}

syntax is the type (<angle>, <color>, <length>, <number>, <percentage>, and friends). initial-value is the fallback when something invalid hits the property. inherits controls whether the value cascades to children — and it’s required. Omit it and the whole rule is silently dropped.

Browser support in 2026: Baseline since 2024. Every engine ships it. No @supports dance, no polyfill, no feature detection. The article that told you to wait is old.

With that out of the way, here’s what you can actually build.

Pattern 1: Animate Gradients Without the background-position Hack

The old way was a magic trick that didn’t actually animate the gradient. You’d make the background twice as wide and slide it sideways:

.button {
  background: linear-gradient(90deg, #4ade80, #22c55e, #4ade80);
  background-size: 200% 100%;
  background-position: 0% 0%;
  transition: background-position 1s ease;
}
.button:hover { background-position: 100% 0%; }

That’s not animating colors. That’s animating a static gradient’s position. The colors never interpolate — the canvas just slides. If you wanted a rotating conic gradient, you were out of luck entirely.

The @property version animates the gradient itself:

@property --angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.glow {
  background: conic-gradient(from var(--angle), #4ade80, #22c55e, #4ade80);
  animation: spin 4s linear infinite;
}

@keyframes spin {
  to { --angle: 360deg; }
}

Six lines and the gradient rotates smoothly because the browser knows --angle is an <angle> and can interpolate between 0deg and 360deg. Without the @property rule, the keyframe would snap from start to end at the 50% mark with nothing in between.

The same trick works on color stops. Register --stop as <percentage> and animate the position where one color blends into the next:

@property --stop {
  syntax: '<percentage>';
  initial-value: 0%;
  inherits: false;
}

.shimmer {
  background: linear-gradient(90deg, transparent, white var(--stop), transparent);
  transition: --stop 600ms ease;
}
.shimmer:hover { --stop: 100%; }

Notice what changed: the animation is on the variable, not on background. That’s the shift. Before, you animated whatever property the gradient lived on. Now you animate the variable the gradient is built from. The type annotation is what makes the difference — the browser only interpolates because <angle> and <percentage> told it how. (If you’re already chaining these with newer CSS, the patterns in CSS scroll-driven animations compose nicely.)

Animation is the visible win. The architectural win is what makes you stop reaching for naming conventions and start reaching for types.

Pattern 2: Type-Safe Custom Properties That Don’t Leak

Here’s a debugging story you’ve probably lived. You set --spacing-md: 16px in :root. Three months later, a junior dev sets --spacing-md: red inside a component override. CSS doesn’t care. The cascade keeps going, half your layout collapses, and you spend twenty minutes bisecting commits.

:root { --spacing-md: 16px; }

.broken-card { --spacing-md: red; }   /* CSS shrugs */
.broken-card .content { padding: var(--spacing-md); }   /* now padding is invalid */

Untyped variables accept anything. They cascade anyway. You only find out at paint time.

@property makes the system enforce its own contract:

@property --spacing-md {
  syntax: '<length>';
  initial-value: 16px;
  inherits: true;
}

.broken-card { --spacing-md: red; }   /* rejected — falls back to 16px */

Invalid values are silently replaced with initial-value. Your layout doesn’t collapse. The mistake gets visible in the inspector instead of in production. That’s not just a nice-to-have — it’s the closest CSS gets to a type system, and it works at runtime, not at build time. (If you’ve felt the same pain in JavaScript, TypeScript without hating it is the corresponding story for app code.)

The inherits flag is the other half. Here’s the framework I use:

The leaky case looks innocent:

@property --card-tilt {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.card { transform: rotate(var(--card-tilt)); }
.card:hover { --card-tilt: 3deg; }

If you’d left inherits: true, hovering a parent card would tilt every nested child card with it. With inherits: false, each card minds its own business. The flag is doing real work — it’s not a stylistic choice.

Once your tokens are typed and your local state is locked down, you can start deriving values instead of hard-coding them. That’s where it gets interesting.

Pattern 3: Computed Color Themes Without JavaScript

Most theming systems are class-swap parties. Light theme adds one set of variables, dark theme adds another, you ship two palettes and a prefers-color-scheme query. It works. It’s also a lot of CSS for what’s really one decision: where on the color wheel does this theme live?

Register a hue, derive the palette:

@property --hue {
  syntax: '<number>';
  initial-value: 220;
  inherits: true;
}

:root {
  --primary:      hsl(calc(var(--hue) * 1deg) 80% 60%);
  --primary-soft: hsl(calc(var(--hue) * 1deg) 80% 92%);
  --accent:       hsl(calc((var(--hue) + 180) * 1deg) 70% 55%);
  --muted:        hsl(calc(var(--hue) * 1deg) 15% 45%);
}

One number drives the whole palette. Want a warm theme? --hue: 12. Cool theme? --hue: 220. Botanical? --hue: 140. Every color shifts in lockstep because they’re all computed from the same source. For more color manipulation patterns, see CSS color-mix.

The kicker: because --hue is a typed <number>, you can transition it.

:root { transition: --hue 800ms ease; }
[data-theme="warm"] { --hue: 12; }
[data-theme="cool"] { --hue: 220; }

Themes crossfade. They don’t snap. The user toggles a theme switcher and the whole palette glides around the color wheel like a continuous knob, not a step function. Without @property, that transition would jump straight from 12 to 220 with no states in between.

Dark mode falls out of the same primitive. Add a second registered property for lightness and let one rule flip both:

@property --base-lightness {
  syntax: '<percentage>';
  initial-value: 60%;
  inherits: true;
}

:root { --primary: hsl(calc(var(--hue) * 1deg) 80% var(--base-lightness)); }
@media (prefers-color-scheme: dark) { :root { --base-lightness: 45%; } }

Same hue, different lightness, no second palette to maintain. Two numbers describe a whole design language. That’s the pattern almost nobody is writing about, and it’s where @property starts to feel less like an enhancement and more like an architecture shift.

This all sounds great until you hit the one trap that breaks every example above silently.

The One Syntax Gotcha That Breaks Animations

You’ll be tempted to write syntax: '*' because the docs mention it accepts anything. Don’t. The universal syntax tells the browser to treat the value as an opaque token list — and it cannot interpolate a token list. Your animation will jump in discrete steps instead of transitioning, or snap at the 50% mark with nothing between.

Symptom checklist when an @property animation refuses to animate:

  1. Is syntax specific? <angle>, <color>, <length>, <number>, <percentage> — not '*'. The universal type kills interpolation.
  2. Is initial-value present and the right type? initial-value: red for a <length> rule means the whole rule is dropped silently.
  3. Is inherits declared? Missing inherits means the rule is invalid and the property falls back to being an untyped string — which is exactly the situation you were trying to escape.

If any of those three fail, the @property rule never registers and you’re back to plain custom-property behavior with no animation.

The 30-second debug: open DevTools, find the element, look at the Computed pane. Registered properties display with their resolved type next to the value. If you see your variable but no type annotation, the registration failed. Fix one of the three above and refresh.

You now know the patterns, the framework for when to use them, and the one mistake that breaks them. Here’s where to actually start.

Where to Start Monday Morning

The reason your CSS variable snapped instead of animating isn’t that CSS animation is broken. It’s that the browser didn’t know what the variable was. Give it a type and the animation happens for free — same feature, same line of code.

Pick one decorative gradient or hover state in your current codebase and convert it to a registered --angle this week. It’s a ten-minute change with an immediately visible win, and once that’s shipped, the architectural pattern (type-safe tokens) and the computed theme pattern compound on top of the same primitive. You’re not learning three things. You’re learning one thing three ways.

If you’re rebuilding your design tokens around this, CSS cascade layers pair well with typed properties — types catch invalid values, layers control who gets to set them.