Animatable CSS variables with typed values, combined with Tailwind CSS
Classic CSS custom properties are plain strings with no type, which is why the browser can't animate them even when the value looks like a number or an angle. The @property rule lets you register custom properties with a genuine syntax type, an inheritance behavior, and an initial value instead, which suddenly makes them animatable. This article covers how to combine @property meaningfully with Tailwind CSS, including a practical example animating a gradient angle rotation.
Table of Contents
- 1. Why classic custom properties aren't animatable
- 2. The syntax of the @property rule in detail
- 3. Benefits of typing beyond pure interpolation
- 4. Animatable custom properties: a gradient angle example
- 5. Integration with Tailwind CSS v4
- 6. The finer points of inherits and initial-value
- 7. Browser support and fallback strategy
- 8. Practical example: an animated button border with Tailwind
- 9. Limits and common pitfalls
- 10. Summary
- 11. FAQ
1. Why classic custom properties aren't animatable
A classic CSS custom property, declared via --angle: 45deg, is internally nothing more than a string as far as the browser is concerned. The browser only interprets that value at the point where the property actually gets consumed via var(), for example in transform: rotate(var(--angle)). Up to that point of use, the browser has no idea whether it's an angle, a color, or any other CSS syntax, and that's exactly what makes animating this property impossible, since animation requires the browser to be able to meaningfully interpolate between two values.
Without a known type, the browser can't know, during a transition from --angle: 0deg to --angle: 360deg, that it should interpolate smoothly between the two angle values. Instead, with a classic custom property the value jumps abruptly from the old to the new value, with none of the intermediate steps that would make a visible animation. That's exactly the gap the @property rule closes, by assigning the custom property a genuine, browser-understood type.
2. The syntax of the @property rule in detail
An @property registration consists of three required fields: syntax defines the allowed value type, for example <angle> for angles, <color> for colors, or <length> for length values. inherits determines whether the property is inherited by child elements, and initial-value defines the value that applies wherever the property hasn't been explicitly set. All three fields are mandatory; an @property rule missing any of the three is discarded as invalid by the browser and ignored.
The registration itself sits as a standalone at-rule block in the stylesheet, independent of any particular selector, similar to @font-face or @keyframes. The property name in the @property rule and the name used later via var() must match exactly, including the two leading dashes, since CSS custom properties are always treated as case-sensitive.
@property --gradient-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.rotating-border {
background: conic-gradient(from var(--gradient-angle), #6366f1, #ec4899, #6366f1);
}
3. Benefits of typing beyond pure interpolation
The most obvious benefit is animatability, but typing brings two more practical improvements along with it. First, the browser validates values against the declared syntax type: if an attempt is made to assign an invalid value like a string to a property typed as <angle>, the browser discards that value and falls back to the initial-value, instead of silently accepting the bad value as a string the way a classic custom property would.
Second, inherits: false can specifically prevent a property from getting unintentionally picked up by child elements, which occasionally causes surprising behavior with classic custom properties that always inherit, for example when a nested element suddenly shows the same gradient angle as its parent even though no value was ever set for it directly. For local, component-scoped values like an animation intermediate state, inherits: false is almost always the right call.
4. Animatable custom properties: a gradient angle example
A concrete example where a typed custom property makes the crucial difference is animating the angle in a conic-gradient(). Without @property, a rotating gradient border can only be built via a @keyframes rule that defines the entire background value with hardcoded intermediate angles, which is inflexible and combines poorly with dynamically computed start or end angles.
With a property typed as <angle>, a transition or @keyframes animation can instead simply target the custom property itself, and the browser automatically interpolates every intermediate angle between the start and target value. That reduces the animation to a single animated property, while the actual gradient declaration stays unchanged and merely refers via var() to the current, changing angle.
@property --gradient-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes rotate-gradient {
to {
--gradient-angle: 360deg;
}
}
.rotating-border {
background: conic-gradient(from var(--gradient-angle), #6366f1, #ec4899, #6366f1);
animation: rotate-gradient 4s linear infinite;
}
5. Integration with Tailwind CSS v4
Tailwind CSS v4 relies on @property internally itself, among other things for some of its built-in gradient and transform utilities, to make their intermediate values animatable. For custom typed properties of your own, Tailwind v4's central CSS file with the @theme directive is a natural place, since it can hold arbitrary additional @property registrations alongside design tokens, keeping the typing in the same place as the rest of the Tailwind configuration.
Actually accessing the typed property within a utility context works via Tailwind's arbitrary-value syntax, for example bg-[conic-gradient(from_var(--gradient-angle),theme(colors.indigo.500),theme(colors.pink.500))], or more pragmatically through a dedicated component class that encapsulates the gradient declaration via @apply or plain CSS. For animations driven by Alpine.js or JavaScript, the current angle can additionally be set directly on the element via an inline style attribute with --gradient-angle: 120deg, with no Tailwind class needed for that at all.
6. The finer points of inherits and initial-value
The inherits flag has an effect that goes beyond plain inheritance: with inherits: false, the browser consistently resets the property to initial-value on every element that hasn't set an explicit value for it, regardless of whatever value applied on the parent element. With inherits: true, on the other hand, the parent element's value gets picked up, exactly as with a classic custom property. For animated states meant to stay independent per element, inherits: false is almost always the right choice.
initial-value must be a valid value for the declared syntax type, otherwise the entire @property rule gets discarded as invalid, not just the faulty field. An @property registration with syntax: "<angle>" and initial-value: 0 without a unit is therefore invalid, since an angle value always requires a unit such as deg, rad, or turn. This strict validation mechanism prevents silent errors, but a typo also produces no console error, the property just goes unused.
7. Browser support and fallback strategy
The @property rule is supported by all current versions of Chrome, Edge, and Safari, Firefox caught up with full support starting at version 128, so the feature is now considered production-ready for most projects. For projects that still need to support older Firefox versions, an unregistered custom property continues to behave like a classic, untyped property, so the layout doesn't break, only the animation of the property itself won't work.
A robust fallback strategy therefore combines a static, non-animated state as the base value with an animation gated behind @supports, activated only in browsers that support @property. That way the element still looks appropriate in older browsers, just without the rotation, instead of ending up in a broken or unexpected state. The check runs via @supports (background: paint(something)) or more directly via feature detection on @property itself, depending on the desired level of rigor.
.rotating-border {
background: conic-gradient(from 0deg, #6366f1, #ec4899, #6366f1);
}
@supports (background: paint(something)) {
@property --gradient-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.rotating-border {
background: conic-gradient(from var(--gradient-angle), #6366f1, #ec4899, #6366f1);
animation: rotate-gradient 4s linear infinite;
}
}
8. Practical example: an animated button border with Tailwind
A common use case is a call-to-action button with a gently rotating gradient border meant to draw attention on a primary button without feeling pushy. The trick is building the button background and the animated gradient border as two stacked layers: an outer layer with the animated conic-gradient() as its background, and a slightly smaller inner layer with the button's actual background, so only a thin strip of the outer gradient stays visible as a border.
Translated into Tailwind classes, that means a relatively positioned outer element carrying the typed custom property and the animation, plus an absolutely positioned inner element with inset-0.5 and the actual button background color, covering most of the outer surface. The text class and the click handler for the button sit on the inner element, so the animated border stays purely decorative and doesn't claim its own interaction surface.
9. Limits and common pitfalls
A common trap is picking a syntax type that's too broad, for example syntax: "*", which allows any arbitrary value but in doing so gives up exactly the type safety and animatability that justify using @property in the first place. An animatable property always needs a concrete type like <angle>, <length>, <color>, or <number>; a generic wildcard type behaves, as far as animation goes, exactly like a classic, untyped custom property.
A second pitfall concerns the registration itself within component-based build systems: if the same @property rule accidentally gets registered multiple times with differing syntax values across a project, for example because two independent components reuse the same property name, whichever definition loads last wins depending on the browser, which can cause inconsistent behavior between development and production environments. A single central location for all @property registrations, for example right next to the Tailwind @theme definition, reliably prevents this problem.
| Field | Required | Example value | Effect |
|---|---|---|---|
| syntax | yes | "<angle>" | Defines the allowed value type and enables interpolation |
| inherits | yes | false | Controls whether child elements pick up the value without their own declaration |
| initial-value | yes | 0deg | Value that applies without an explicit assignment or on an invalid value |
| @supports fallback | recommended | @supports (background: paint(something)) | Static state for browsers without @property support |
Mironsoft
Tailwind CSS architecture, design systems, and performance
Tailwind frontends that stay maintainable despite thousands of utility classes?
We review existing Tailwind projects for bloated class lists, inconsistent design tokens, and unused CSS remnants, then build a design system that scales cleanly instead of getting messier with every component.
Design System Review
Checking tokens, spacing scale, and component consistency for maintainability.
Performance Optimization
Systematically reducing CSS bundle size, purge configuration, and load times.
Component Architecture
Building reusable, well-structured components instead of sprawling class lists.
10. Summary
@property with Tailwind: The Essentials at a Glance
Core idea
@property gives a custom property a genuine type, letting the browser interpolate between values and animate the property.
Required fields
syntax, inherits and initial-value must all three be set, otherwise the entire registration is discarded as invalid.
Practical example
An animated conic-gradient() angle for rotating button borders, not animatable with classic custom properties.
Fallback
A static, non-animated base state outside @supports, with the animation activated only in supporting browsers.