CSS @property: Typed Custom Properties and Animatable Variables

Custom properties have been in CSS since 2016, and for most of that time they have been a very useful kind of string. You write --brand: #6366f1, you read it back with var(--brand), and the browser treats the value as an opaque stream of tokens that it substitutes textually before parsing the property that consumed it. That design is deliberate and it is why custom properties can hold almost anything, including fragments of CSS that are not valid on their own.

It also explains the single most common frustration developers hit with them: you cannot animate a custom property. Put --angle: 0deg in one keyframe and --angle: 360deg in another and nothing smooth happens. The @property at-rule is the fix. It lets you register a custom property — give it a real type, decide whether it inherits, and supply an initial value — and once a property is registered the browser can parse it, validate it, and interpolate it like any built-in property.

This guide covers what registration actually changes, the three descriptors and their traps, the patterns that only become possible once your variables are typed (animated gradients, conic progress rings, counting numbers), and the handful of gotchas that will otherwise cost you an afternoon — including one about var() fallbacks that reverses behaviour you may have been relying on for years.

Why Untyped Custom Properties Cannot Animate

Start with the failure, because understanding it makes everything else obvious. Here is a gradient that looks like it should rotate:

/* This does NOT work as expected */
.card {
  --angle: 0deg;
  background: linear-gradient(var(--angle), #6366f1, #ec4899);
  transition: --angle 400ms ease;
}

.card:hover {
  --angle: 180deg;
}

On hover the gradient does change, but it jumps. There is no sweep from 0 to 180 degrees. The reason is that --angle has no type. To the browser its value is the token sequence 0deg, not the number 0 paired with the unit deg. When the animation engine asks “what is 40% of the way between these two values?” there is no arithmetic it can legally perform on two token streams.

The specification is explicit about this: values that cannot be interpolated animate discretely, meaning the browser holds the start value until the halfway point of the animation and then flips to the end value. So the transition above is not ignored — it runs for the full 400ms and produces exactly one visible change, at 200ms. That is why the symptom is often described as “the transition duration seems to work but the animation doesn't.”

Registering the property changes the value from a token stream into a typed value:

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

.card {
  background: linear-gradient(var(--angle), #6366f1, #ec4899);
  transition: --angle 400ms ease;
}

.card:hover {
  --angle: 180deg;
}

Now the gradient sweeps. Nothing else in the rule changed. The browser knows --angle is an <angle>, so it can compute intermediate angles, and because the gradient re-resolves on every frame the paint follows along.

The Three Descriptors

An @property rule takes a name that must begin with --, and up to three descriptors. Two are always required and the third is required in practice.

@property --card-radius {
  syntax: '<length>';     /* required: the type */
  inherits: false;         /* required: inheritance behaviour */
  initial-value: 8px;      /* required unless syntax is '*' */
}

The rule is atomic. If any required descriptor is missing or malformed, the entire @property rule is invalid and dropped — and it is dropped silently. The custom property still works, because unregistered custom properties always work, so what you observe is not an error in the console but a feature that mysteriously fails to animate. Whenever a typed-property animation does nothing, check the descriptors before you check anything else.

syntax: the type

The syntax descriptor is a string describing the grammar of accepted values. The supported primitives are:

Three combinators let you build compound grammars: | for alternatives, + for a space-separated list, and # for a comma-separated list.

/* Either a length or the keyword auto */
@property --gutter {
  syntax: '<length> | auto';
  inherits: true;
  initial-value: auto;
}

/* A space-separated list of lengths, e.g. "4px 8px 4px" */
@property --pad-set {
  syntax: '<length>+';
  inherits: false;
  initial-value: 0px;
}

/* A comma-separated list of colours */
@property --stops {
  syntax: '<color>#';
  inherits: false;
  initial-value: black;
}

/* An enum of literal keywords — no angle brackets */
@property --density {
  syntax: 'compact | cozy | comfortable';
  inherits: true;
  initial-value: cozy;
}

Two things are worth flagging. First, a keyword enum like --density is not animatable in any useful sense — keywords interpolate discretely — but registering it still buys you type checking and controlled inheritance, which is the point. Second, the universal syntax * deliberately gives up type checking and animation; it exists so you can register a property purely to control its inheritance, and it is the only case where initial-value may be omitted.

inherits: the inheritance switch

Unregistered custom properties always inherit. Registration is the only way to opt out, and inherits: false is the right default for anything component-local.

@property --card-elevation {
  syntax: '<number>';
  inherits: false;
  initial-value: 1;
}

With inherits: false, an element that does not set --card-elevation computes it to 1 rather than picking up whatever an ancestor happened to set. This prevents a class of bug that is genuinely hard to debug in nested components: a card inside a card silently adopting the outer card's elevation, hue, or spacing multiplier. If a value describes “this element” rather than “this subtree,” turn inheritance off.

It also matters for animation. If you animate an inherited custom property on a container, every descendant that reads it must be re-resolved on every frame. Scoping the animation to a non-inheriting property on the single element that needs it keeps the work proportional to what actually changes.

initial-value: the value before anything is set

The initial value is what a registered property computes to when no declaration applies, and it doubles as the recovery value when a declaration is invalid. There is one restriction that trips people up: it must be computationally independent, meaning its computed value cannot depend on anything else on the element.

/* Valid: absolute units resolve without context */
@property --gap {
  syntax: '<length>';
  inherits: false;
  initial-value: 16px;
}

/* INVALID: em depends on the element's font-size,
   so the whole rule is thrown away */
@property --gap-bad {
  syntax: '<length>';
  inherits: false;
  initial-value: 1em;
}

Percentages are similarly rejected for a <length>-typed property because they resolve against a layout dimension. Use absolute units in initial-value and apply relative sizing in the declarations that consume the property.

Type Checking Is a Real Feature

Animation gets the attention, but validation is the part that improves large codebases. An unregistered custom property accepts anything and defers the consequences until substitution:

.thing {
  --size: potato;                 /* accepted, no complaint */
  width: calc(var(--size) * 2);   /* width is now invalid at
                                     computed-value time, and
                                     falls back to auto */
}

The mistake is in --size, but the breakage surfaces in width, possibly in a different file written by a different person. Registering --size as a <length> moves the failure back to its cause:

@property --size {
  syntax: '<length>';
  inherits: false;
  initial-value: 10px;
}

.thing {
  --size: potato;   /* rejected: --size computes to 10px */
  width: calc(var(--size) * 2);  /* 20px */
}

The invalid declaration is discarded and the property recovers to a known-good value: the inherited value if inherits: true, otherwise the initial value. This is called being invalid at computed-value time. The practical effect is that a typo degrades one property to a sane default instead of cascading into an unstyled component. For a design-token layer — spacing scales, radii, brand colours — that guarantee is worth the handful of extra lines on its own, even if you never animate any of it.

Type checking also pairs neatly with style queries. Because --density in the earlier example can only ever hold one of three keywords, a @container style() query against it cannot be defeated by a stray value:

@container style(--density: compact) {
  .row { padding-block: 0.25rem; }
}

See the container queries guide for how style queries fit into component-level responsive design.

Animating Gradients

Gradients are not interpolable in CSS. You cannot transition background-image from one linear-gradient() to another and get a smooth result. But you can animate the typed custom properties a gradient is built from, and because the gradient is recomputed each frame the visible result is a smoothly animating gradient. This is the single most popular use of @property.

A rotating conic border

The spinning gradient border you have seen on landing pages is a conic gradient whose starting angle is an animated typed property:

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

.glow-card {
  position: relative;
  border-radius: 12px;
  background: canvas;
}

.glow-card::before {
  content: '';
  position: absolute;
  inset: -2px;
  z-index: -1;
  border-radius: inherit;
  background: conic-gradient(
    from var(--spin),
    #6366f1, #ec4899, #f59e0b, #6366f1
  );
  animation: spin 4s linear infinite;
}

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

@media (prefers-reduced-motion: reduce) {
  .glow-card::before { animation: none; }
}

Two details make this production-ready rather than a demo. The @keyframes block only needs a to frame, because the from value is supplied by initial-value. And the prefers-reduced-motion block is not optional politeness — a continuously rotating element is exactly the kind of motion that triggers discomfort for users with vestibular sensitivities. The gradient still renders, it simply holds still.

A hover-driven gradient sweep

For interaction rather than ambience, transitions are usually a better fit than infinite animations, because they run only while something is changing:

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

.cta {
  background: linear-gradient(var(--sweep), #6366f1, #22d3ee);
  transition: --sweep 500ms cubic-bezier(0.4, 0, 0.2, 1);
}

.cta:hover,
.cta:focus-visible {
  --sweep: 315deg;
}

Including :focus-visible alongside :hover keeps the affordance available to keyboard users. Use the cubic bezier editor to tune the curve, and the gradient generator to design the gradient before you parameterise it.

Animating colour stops instead of angles

Angles are the obvious knob, but any part of the gradient can be a typed property. Animating a <percentage> stop position produces a wipe:

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

.reveal {
  background: linear-gradient(
    90deg,
    #6366f1 var(--wipe),
    transparent 0
  );
  transition: --wipe 600ms ease-out;
}

.reveal:hover { --wipe: 100%; }

A registered <color> property works the same way and interpolates using the ordinary rules for CSS colour animation, so the colour space caveats from the modern CSS colour guide apply here too — mixing through the wrong space is how gradients end up passing through grey.

A Progress Ring in Pure CSS

Combining a typed percentage, a conic gradient, and a radial mask gives you a progress ring with no SVG and no JavaScript beyond setting one number:

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

.ring {
  --size: 96px;
  --thickness: 10px;

  inline-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;

  background: conic-gradient(
    #6366f1 var(--progress),
    color-mix(in oklch, canvastext 12%, transparent) 0
  );

  /* Punch out the centre to turn the pie into a ring */
  mask: radial-gradient(
    farthest-side,
    transparent calc(100% - var(--thickness)),
    black calc(100% - var(--thickness) + 1px)
  );

  transition: --progress 800ms ease-out;
}
<div class="ring" style="--progress: 68%"
     role="progressbar"
     aria-valuenow="68" aria-valuemin="0" aria-valuemax="100">
</div>

Updating the ring is now el.style.setProperty('--progress', '82%') and the transition handles the rest. The ARIA attributes are doing the accessibility work, since a background gradient conveys nothing to a screen reader; keep aria-valuenow in sync whenever you change the custom property.

Counting Numbers Without JavaScript

Because <integer> is interpolable and CSS counters can read a custom property, you can animate a number counting up:

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

.stat {
  counter-reset: stat var(--count);
  animation: count-up 2s ease-out forwards;
}

.stat::after {
  content: counter(stat);
}

@keyframes count-up {
  to { --count: 1250; }
}

This is a genuine crowd-pleaser, with a real caveat: the number lives in generated content, and generated content is announced inconsistently across screen readers, so a value that matters should also exist as real text in the DOM. Treat the animation as decoration over an accessible number rather than as the number itself.

@property vs CSS.registerProperty()

The at-rule has a JavaScript twin from the CSS Properties and Values API:

CSS.registerProperty({
  name: '--spin',
  syntax: '<angle>',
  inherits: false,
  initialValue: '0deg',
});

Note the camelCase initialValue, which differs from the hyphenated CSS descriptor. Prefer the at-rule. It lives beside the styles that depend on it, requires no script, and cannot lose a race with first paint — whereas a property registered in JavaScript is unregistered until that script runs, so an animation starting earlier may render its first frames discretely. registerProperty() also throws an InvalidModificationError if the property is already registered, which makes it awkward in code that may run more than once.

Reach for the JavaScript form only when the registration itself is dynamic — a theming engine registering properties it discovered at runtime, for example — and wrap it defensively:

try {
  CSS.registerProperty({ name: '--spin', syntax: '<angle>',
                         inherits: false, initialValue: '0deg' });
} catch (err) {
  /* Already registered, or unsupported. Both are fine:
     the property still works, it just won't interpolate. */
}

Gotchas Worth Knowing Before You Ship

Registration disables the var() fallback

This one reverses a habit. The second argument to var() is used only when the property is guaranteed-invalid — the state an unset custom property is in. A registered property with an initial-value is never guaranteed-invalid, because it always has a valid computed value. So the fallback becomes unreachable:

@property --accent {
  syntax: '<color>';
  inherits: false;
  initial-value: rebeccapurple;
}

.thing {
  /* Resolves to rebeccapurple, NEVER to hotpink,
     even though --accent was never set here. */
  color: var(--accent, hotpink);
}

If you were using var() fallbacks as your default-value mechanism, registering those properties will change what renders. The fix is straightforward — the initial-value is now the single source of the default — but it is a behaviour change to look for when you retrofit @property onto an existing token layer.

The rule is thrown away silently

Worth repeating because it accounts for so much lost time: a missing initial-value, a relative unit in it, or a typo in the syntax string invalidates the whole rule with no console error. Your animation then degrades to discrete steps, which reads as “@property is broken” rather than “my rule was rejected.” When debugging, inspect the element's computed value for the property: if it shows your raw token text rather than a resolved typed value, the registration did not take.

The syntax string needs quotes

syntax: '<angle>' is correct; syntax: <angle> is not. The descriptor takes a string, and an unquoted value is a parse error — which, per the previous gotcha, discards the rule silently.

Duplicate registrations follow the cascade order

If the same property name is registered twice, the last @property rule in document order wins, in the same way later declarations override earlier ones. This is easy to hit when several component stylesheets each register a shared token with slightly different initial values. Register shared tokens in exactly one place.

Custom property animations are not composited

Animations on transform and opacity can be handed to the compositor and run off the main thread. Custom property animations cannot. Each frame recomputes the property, and if it feeds a gradient, the element repaints. One rotating border is imperceptible; forty of them on a long page will show up in a performance profile. Keep the painted area small, prefer transitions that run only during interaction over infinite animations, and reach for transform when a transform would achieve the same look.

Browser Support

@property is supported in Chrome and Edge 85+, Safari 16.4+, and Firefox 128+. Firefox was the last of the three engines to ship it, in July 2024, which is the point at which the feature became Baseline Newly Available; on the usual 30-month schedule it reaches Baseline Widely Available in early 2027.

The degradation story is unusually kind. In a browser without support the @property rule is ignored, but the custom property itself still works, because unregistered custom properties have always worked. Colours, sizes, and gradients all render correctly — the only loss is interpolation, so animations snap instead of sweeping. For decorative motion that is an acceptable fallback with no extra code. If a design depends on the interpolation being visible, gate it:

/* Only apply the animated treatment where
   the at-rule is actually understood. */
@supports at-rule(@property) {
  .glow-card::before { animation: spin 4s linear infinite; }
}

Be aware that @supports at-rule() is itself a recent addition, so in older browsers the guarded block is skipped — which errs in the safe direction for progressive enhancement, but does mean some browsers that support @property may not get the animation. When you need certainty, do the check in JavaScript with typeof CSS.registerProperty === 'function' and set a class on the root element. Check the caniuse data for the current numbers before you decide which approach your audience justifies.

When to Register and When Not to Bother

Register a custom property when at least one of these is true: you need to animate or transition it; it is part of a token layer where a bad value should fail loudly at its source instead of quietly downstream; or it describes a single element and must not leak into descendants via inheritance.

Do not bother when the property holds a fragment of CSS rather than a value — a shorthand, a list of declarations, a partial selector. The universal syntax would accept it but there is nothing to type-check or interpolate, so registration buys you only the inheritance switch. And skip it for one-off values inside a single component that are read once and never animated; three lines of @property to describe a value used in one place is the kind of ceremony that makes a stylesheet harder to read, not easier.

A reasonable middle path for an existing codebase: register the tokens you animate, plus the numeric and length tokens in your design scale where a typo is most costly, and leave the rest alone. You can adopt this incrementally, one property at a time, with no build step and no migration.

Further Reading