@property directive

[check: …]
📖 Pair this live Tailwind demo with the companion guide (.md) — this page is the rendered ground truth. ↗ Cross-links: arbitrary_properties (inline [--x:val] sets vars but does NOT type them — @property does), and keyframes_animate (animations on registered vars are the whole point).

untyped CSS var → typed @property: the upgrade

A plain custom property like --angle: 0deg is, to the browser, an untyped string. It can hold "0deg" or "banana" — the engine has no idea it is an angle, so it cannot interpolate it. Animate it and the value just snaps between keyframes. @property registers the property with the engine: a syntax type, an initial-value, and an inherits flag. Now the browser knows how to glide from 0deg to 360deg in smooth steps. Tailwind v4 emits @property for its theme tokens for exactly this reason.

aspectplain --x (unregistered)@property --x (registered)
browser sees it as an opaque string a typed value (<angle>, <color>, …)
animatable? ❌ no — discrete jumps at the 50% mark (snaps) yes — smooth interpolation between keyframe values
missing / invalid value empty string (often breaks the consuming var()) falls back to initial-value — predictable
inheritance always inherited (you can't opt out) explicit: inherits: true | false
transition: --x 1s ❌ ignored (no interpolation path) ✅ works — the value glides for the duration
how to declare --x: 200px; on any selector a @property --x {…} block (or CSS.registerProperty())
@property --angle { syntax: '<angle>'; /* the TYPE — tells the browser how to interpolate */ initial-value: 0deg; /* fallback when unset or invalid */ inherits: false; /* explicit inheritance (plain vars always inherit) */ } /* Now --angle is animatable. Without @property this keyframe would just snap. */ @keyframes spin { to { --angle: 360deg; } } .spinner { --angle: 0deg; /* set initial value on the element */ animation: spin 1.4s linear infinite; transform: rotate(var(--angle)); /* consumer reads the smoothly-changing var */ }

1 · animated spinner — @property --angle + @keyframes

The circle below rotates by reading var(--angle) in its transform. The @keyframes spin-typed animation animates --angle itself from 0deg to 360deg. This only works because @property --angle told the browser that --angle is an <angle>, so it can interpolate. The live readout samples the computed value every frame — watch the degrees climb smoothly.

--angle: (sampling…)

distinct values seen: 0

The value changes every frame → the browser is interpolating the registered property. That is impossible for an untyped var.

Why not just animate transform directly? You can — but @property lets you animate the angle value itself and reuse it elsewhere (a second spinner, a gradient stop, a clip-path) all driven by one variable. It composes; raw transform animations do not.

2 · with vs without @property — same keyframes, different physics

Two identical spinners, identical @keyframes (0deg → 360deg, 1.4s linear infinite). The ONLY difference: the left registers --angle with @property; the right uses an unregistered --raw-angle. Watch them — and watch the live value samplers. The left glides through dozens of distinct angles; the right snaps between just two values (so 0deg↔360deg look identical and the spinner appears frozen).

✅ registered --angle

--angle: …

distinct: 0

❌ unregistered --raw-angle

--raw-angle: …

distinct: 0

/* LEFT — registered: smooth rotation */ @property --angle { /* THE ONLY DIFFERENCE */ syntax: '<angle>'; initial-value: 0deg; inherits: false; } @keyframes spin-typed { to { --angle: 360deg; } } .spinner-typed { --angle: 0deg; animation: spin-typed 1.4s linear infinite; transform: rotate(var(--angle)); } /* RIGHT — unregistered: --raw-angle is a string, animates DISCRETELY (snaps) */ @keyframes spin-raw { to { --raw-angle: 360deg; } } /* no @property! */ .spinner-raw { --raw-angle: 0deg; animation: spin-raw 1.4s linear infinite; transform: rotate(var(--raw-angle, 0deg)); }

3 · animated gradient on hover — @property + transition

Hover (or tap) each card. The top card registers --grad-pos as <percentage> and transition: --grad-pos .6s ease — the gradient stop glides. The bottom card uses an unregistered --grad-pos-raw with the same transition rule — but the transition is ignored, so the stop snaps instantly. Same CSS, only the @property block differs.

✅ registered --grad-pos — glides

❌ unregistered --grad-pos-raw — snaps

Trick: the gradient's middle stop is var(--grad-pos). Animating a linear-gradient() argument directly is impossible — but animating the registered variable inside it works, because the browser interpolates the percentage.

4 · syntax types — what you can register

The syntax descriptor is the heart of @property. It accepts a single type, a multi-value list (+ or #), or the universal *.

syntaxexample valuesanimates as
<length>200px, 1.5rem, 0real-number distance — smooth
<percentage>50%, 0%0–100 ratio — smooth
<angle>0deg, 1.5turn, 200graddegree value — smooth (this demo's --angle)
<time>0.3s, 250msseconds — smooth
<number>0, 1.5, 42plain number — smooth
<integer>0, 7, -3whole steps — discrete jumps
<color>#06b6d4, oklch(0.7 0.15 250)color space blend — smooth
<image>url(a.png), linear-gradient(...)cross-fade — smooth
<url>url(icon.svg)discrete (can't blend URLs)
<resolution>2dppx, 96dpipixels-per-unit — smooth
<length> | <percentage>200px or 50%"or" of two types — smooth
<length>+10px 20px 30pxspace-separated list — each term interpolates
*anythingdiscrete (browser can't assume a type) — no real interpolation

Gotcha: <integer>, <url>, and * animate discretely even when registered — there is no smooth path between two URLs or two integers. Use <number> if you want smooth numeric steps.

5 · inheritance + the JS twin (CSS.registerProperty)

Plain custom properties always inherit — a child sees the parent's value whether you want it to or not. @property makes this explicit with inherits: false (default for most tokens — avoids accidental cascade leakage) or inherits: true (for theme-level vars like --color-brand that should propagate).

formwhereuse when
@property --x { … } CSS (declarative) static, design-time tokens — ship with the stylesheet
CSS.registerProperty({ name, syntax, initialValue, inherits }) JS (imperative) runtime-defined properties (rare) — throws if the name is already registered
// The JS twin of the @ declarative form. One-time registration. CSS.registerProperty({ name: '--js-angle', syntax: '<angle>', initialValue: '0deg', inherits: false }); // After this, --js-angle behaves exactly like a @property --js-angle block.

6 · how Tailwind v4 uses @property internally

Tailwind v4's generated CSS emits @property for its theme tokens so they are typed, have safe initial values, and don't leak via inheritance. This is why bg-blue-500 resolves cleanly and why var(--color-blue-500) has a sensible fallback when unset. (Illustrative — not live-compiled by this page.)

/* Approximation of what Tailwind v4's build emits for theme tokens. */ @property --color-blue-500 { syntax: '<color>'; inherits: false; initial-value: oklch(0.623 0.214 259.815); /* the OKLCH palette value */ } @property --spacing { syntax: '<length>'; inherits: false; initial-value: 0.25rem; /* the base spacing multiplier */ } /* Now utilities like p-4 (= calc(var(--spacing) * 4)) have a typed base, and var(--color-blue-500) has a guaranteed fallback color. */

Net effect: every Tailwind color/spacing/radius token is a registered custom property, so they resolve predictably and compose safely with your own var() usage. See oklch_colors for the values themselves.

intent → pattern

intentpatternwhy
animate a custom property @property --x { syntax: '<angle>'; initial-value: 0deg; inherits: false; } registration is the ONLY way @keyframes/transition can interpolate a var
transition on hover .card { --p: 0%; transition: --p .6s ease; } .card:hover { --p: 60%; } works only because --p is a registered <percentage>
drive several rules from one angle transform: rotate(var(--angle)); mask-position: var(--angle); one registered var, many consumers — composes unlike raw transform animations
opt OUT of inheritance inherits: false plain vars always inherit; registration lets you scope a var to one element
safe fallback for unset var initial-value: 0deg replaces the empty string an unregistered var returns when unset
register at runtime (JS) CSS.registerProperty({ name:'--x', syntax:'<length>', initialValue:'0px', inherits:false }) imperative twin of @property — throws on duplicate registration
typed Tailwind token @theme { --color-brand: oklch(0.7 0.15 250); } Tailwind v4 wraps theme tokens in @property automatically — you get typing for free
list of values syntax: '<length>+' space-separated list (+) — each term interpolates independently