EmpireUI
Get Pro
← Blog7 min read#css#custom-properties#animation

@property Rule in CSS: Type-Safe Custom Properties with Animation

The CSS @property rule gives custom properties real types and default values — and finally makes them animatable. Here's how to use it without losing your mind.

Code editor showing CSS custom properties and animation keyframes on a dark screen

What @property Actually Does (And Why You Should Care)

Honestly, CSS custom properties are one of the best things to happen to front-end development in years — but they've always had a dirty secret. The browser treats every --variable as a string. That's it. A string. Which means when you try to animate --opacity from 0 to 1, nothing happens. The browser has no idea those values represent numbers.

That's exactly the problem @property solves. It's part of the CSS Houdini spec — the same family of APIs that powers things like CSS Houdini paint worklets — and it lets you register a custom property with a real syntax, an initial value, and an inheritance flag. Once registered, the browser can interpolate it just like any native CSS property.

Browser support landed in Chrome 85 and Edge 85 back in 2020. Firefox shipped it in v128 (mid-2024). Safari added it in 16.4. We're comfortably past the 'wait and see' phase. You can use this in production today.

The Syntax: Breaking Down a @property Declaration

The rule has three required descriptors: syntax, inherits, and initial-value. Skip any of them and the registration silently fails in most browsers — which is a maddening debugging experience the first time it happens to you.

Here's a minimal example that registers a custom property for a gradient angle:

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

.card {
  background: conic-gradient(
    from var(--gradient-angle),
    #a78bfa,
    #60a5fa,
    #a78bfa
  );
  animation: spin-gradient 4s linear infinite;
}

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

The syntax descriptor accepts the same value types as CSS property value definitions: <color>, <number>, <length>, <percentage>, <angle>, <integer>, and a handful of others. You can also combine them with | or use * to accept anything (same as the old untyped behavior). The initial-value is required whenever inherits is false — without it, the registration fails entirely.

Animating CSS Custom Properties: The Real Payoff

Before @property, animating a gradient was basically impossible in pure CSS. You could fade the whole element with opacity, or you could fake it with pseudo-elements and transition: opacity. Both approaches are workarounds. With typed custom properties, gradients become first-class animation citizens.

The spinning gradient in the example above works because the browser now knows --gradient-angle is an <angle>. It can interpolate between 0deg and 360deg the same way it interpolates transform: rotate(0deg) to transform: rotate(360deg). No JavaScript. No requestAnimationFrame. Just CSS.

This same technique applies to color stops, blur amounts, shadow spreads — anything you'd normally have to reach for JavaScript to animate. It's particularly satisfying paired with glassmorphism effects, where you might want to animate backdrop-filter: blur() values or background alphas through a custom property.

One practical heads-up: @keyframes referencing a custom property only works if the property is registered. If you're using @property in a component library or design system, make sure the registration happens before the animation runs. In practice, putting @property declarations in your global stylesheet or a high-priority CSS layer handles this reliably.

JavaScript Registration with CSS.registerProperty()

You can also register properties from JavaScript using CSS.registerProperty(). The API mirrors the CSS syntax closely and is useful when you need to register properties conditionally, or when you're building tools that generate dynamic property names.

// Check support first — older browsers will throw
if ('registerProperty' in CSS) {
  CSS.registerProperty({
    name: '--card-glow-alpha',
    syntax: '<number>',
    inherits: false,
    initialValue: '0',
  });
}

The JS version is identical in behavior but gives you the if gate for graceful degradation. One subtle difference: the initialValue key in JavaScript is camelCase, while initial-value in CSS is hyphenated. Mix them up and you'll spend 20 minutes wondering why it's broken.

If you're building React components that rely on typed custom properties, you'll typically want to call CSS.registerProperty() in a module-level side effect or a useEffect with an empty dependency array. Just be aware it throws a DOMException if you try to re-register the same property name, so guard with a try/catch or check first.

Type-Safe Theming: Using @property in Design Systems

Here's a pattern that's genuinely useful in component libraries. Instead of storing theme tokens as raw strings in CSS variables, you register each token with its actual type. A spacing token becomes a <length>, a color becomes a <color>, an easing curve stays a string (no syntax for that yet, unfortunately).

@property --theme-accent {
  syntax: '<color>';
  inherits: true;
  initial-value: #7c3aed;
}

@property --theme-radius {
  syntax: '<length>';
  inherits: true;
  initial-value: 8px;
}

:root {
  --theme-accent: #7c3aed;
  --theme-radius: 8px;
}

/* Dark mode override — the browser knows these are colors/lengths now */
@media (prefers-color-scheme: dark) {
  :root {
    --theme-accent: #a78bfa;
  }
}

.btn {
  background: var(--theme-accent);
  border-radius: var(--theme-radius);
  /* You can even do math on typed length variables */
  padding: calc(var(--theme-radius) / 2) calc(var(--theme-radius) * 1.5);
}

Notice inherits: true on both properties — that's intentional for theme tokens you want to cascade down the tree. The ability to do calc() on a <length> typed variable and have it actually work is underrated. With untyped variables, calc(var(--theme-radius) / 2) works by luck of string concatenation. With typed variables, the math is evaluated correctly.

This approach pairs well with theme toggling in React. You register the properties once globally, then toggle a class or data attribute on :root to swap the values. The transition between themes can even be animated because the browser knows the type.

Practical Gotchas and Browser Quirks

You can't use @property inside a @layer. That's a current spec limitation — property registrations need to be at the top level of a stylesheet. If your build pipeline wraps everything in cascade layers (a pattern that's becoming common with Tailwind v4.0.2's new layer architecture), you'll need to hoist your @property declarations outside those layers.

The syntax descriptor doesn't support all value types you might expect. There's no <time>, no <easing-function>, and no <custom-ident> with enumerated values yet. If you need to animate a duration or easing, you're stuck converting to unitless numbers and multiplying in calc(). Awkward, but workable.

Also worth knowing: the initial-value is used as a fallback when the property isn't set anywhere in the cascade. This differs from CSS variable fallbacks in var(--thing, fallback) — the @property initial value kicks in at property definition time, not at use time. That makes it more predictable, but it also means you can't use the initial value as a sentinel for 'not set'.

What happens when @property isn't supported? The custom property falls back to untyped string behavior. Animations won't work, but static values will. For most use cases — especially theming — that's an acceptable degradation. For animation specifically, test whether the missing animation is a minor visual enhancement or core to the UX before shipping.

Combining @property with CSS Paint Worklets and Canvas

The full potential of typed custom properties shows up when you combine them with the broader Houdini ecosystem. A CSS Houdini paint worklet can read custom properties passed to it via inputProperties, and when those properties are typed, the worklet receives actual typed values rather than raw strings it has to parse.

For more complex motion work, typed custom properties also integrate cleanly with canvas animations in React. A common pattern is using @property-animated CSS variables to drive canvas rendering — you read the computed value of the property in JavaScript on each frame and use it to position elements on a canvas.

@property --particle-progress {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

.particle-trigger {
  --particle-progress: 0;
  animation: particle-run 2s ease-out forwards;
}

@keyframes particle-run {
  to { --particle-progress: 1; }
}
// In your canvas RAF loop
const el = document.querySelector('.particle-trigger');
const progress = parseFloat(
  getComputedStyle(el).getPropertyValue('--particle-progress')
);
// progress is now a smoothly interpolated float between 0 and 1
drawParticles(ctx, progress);

Is this pattern overkill compared to just running the animation entirely in JavaScript? Sometimes, yes. But it lets you define easing, duration, and delay entirely in CSS — where designers can tweak them without touching JS — while still driving canvas rendering from the interpolated values.

When to Reach for @property vs. Other Animation Approaches

Not every animation needs @property. If you're animating opacity, transform, color, or other native properties, you don't need typed custom properties at all — the browser already knows how to interpolate those. @property earns its complexity budget when you're animating something that would otherwise require JavaScript: gradient positions, blur values, custom shadow configurations, or anything stored in a design token.

For complex timeline-based animations, libraries like Lottie (covered in our Lottie animations guide) or GSAP are better fits. @property shines for looping effects, hover transitions, and theme animations — cases where you want the browser to handle interpolation without JavaScript overhead.

The rule of thumb: if you can express the animation entirely in CSS keyframes and transitions and the only thing stopping you is untyped custom properties, @property is the right fix. If you need sequencing, scroll-driven timelines, or physics, you're probably reaching for JavaScript anyway and @property is optional.

For Empire UI components specifically, we use @property registrations for gradient border animations, spotlight hover effects, and the morphing background transitions in several card styles. The registration happens in a dedicated properties.css file that's imported before any component styles — keeps things predictable and avoids the 'registration order matters' footgun.

FAQ

Can I use @property inside a CSS @layer block?

No — this is a current spec limitation. @property declarations must be at the top level of a stylesheet, outside any @layer blocks. If your build tool wraps styles in cascade layers (common with Tailwind v4.0.2), hoist @property declarations to a separate file imported before the layered styles.

Why does my @keyframes animation on a custom property not work?

The most common reason is a missing or malformed @property registration. All three descriptors are required: syntax, inherits, and initial-value. If any one is missing, the registration silently fails and the property remains an untyped string. Also confirm the @property declaration is at the stylesheet top level, not inside a selector or @layer.

Does @property work with CSS transitions, not just @keyframes?

Yes. Once a custom property is registered with a numeric or color type, both transition and @keyframes can interpolate it. A registered <color> property on a button can use transition: --my-color 0.2s ease for hover effects, same as any native color property.

What's the difference between the initial-value in @property and the fallback in var()?

They're evaluated at different times. The @property initial-value is used when the property has no value anywhere in the cascade — it's baked into the registration. The var() fallback is used at the point of consumption if the variable resolves to an invalid or unset value. They can coexist: @property provides the baseline default, var() provides a use-site override.

Can I register a custom property that accepts multiple value types, like a length or a percentage?

Yes. Use the pipe syntax in the syntax descriptor: syntax: '<length> | <percentage>'. The browser will accept either type and interpolate appropriately. You can also append + for space-separated lists or # for comma-separated lists of that type.

How do I handle browsers that don't support @property?

Static values in custom properties work fine without @property — the property just behaves as an untyped string. Animations that depend on typed interpolation simply won't run. Use @supports to detect support: @supports (background: paint(anything)) catches Houdini-capable browsers, though @property itself has wider support than paint worklets. For most cases, testing with CSS.registerProperty in JavaScript and catching the DOMException is the cleanest feature-detect.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Zen: Writing Maintainable Styles With Modern Cascade ToolsView Transitions API: Cross-Document Animations in 2026CSS Flip Card: 3D Rotate Animation With and Without JavaScriptCSS View Transitions Advanced: Cross-Document, Custom Animations