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.
| aspect | plain --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()) |
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
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 *.
| syntax | example values | animates as |
|---|---|---|
<length> | 200px, 1.5rem, 0 | real-number distance — smooth |
<percentage> | 50%, 0% | 0–100 ratio — smooth |
<angle> | 0deg, 1.5turn, 200grad | degree value — smooth (this demo's --angle) |
<time> | 0.3s, 250ms | seconds — smooth |
<number> | 0, 1.5, 42 | plain number — smooth |
<integer> | 0, 7, -3 | whole 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, 96dpi | pixels-per-unit — smooth |
<length> | <percentage> | 200px or 50% | "or" of two types — smooth |
<length>+ | 10px 20px 30px | space-separated list — each term interpolates |
* | anything | discrete (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).
| form | where | use 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 |
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.)
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
| intent | pattern | why |
|---|---|---|
| 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 |