Registering Custom Properties with @property

Part of Keyframe Architecture & State Mapping in Core CSS Animation Fundamentals.

The problem

You write a transition on a custom property — transition: --card-lift 300ms — set the property from 0px to 12px, and the element does not move smoothly. It stays put for 150 ms and then jumps the whole distance. Nothing in the declaration is wrong, no console message appears, and the same transition on a real property works perfectly.

This is the single behaviour that stops most teams from building a custom-property-driven state layer, and it has a one-rule fix.

Root cause: the browser does not know what the value means

A custom property is, by default, an untyped token stream. --card-lift: 12px stores the characters 12px; the engine does not know they represent a length until something substitutes them into a property that expects one. That substitution happens at use time, long after the cascade has decided what the property’s value is.

Interpolation, though, has to happen on the property itself. To produce a value halfway between two token streams, the engine needs to know they are lengths and that halfway means arithmetic rather than string mixing. With no type, the specification defines the only interpolation that always makes sense for arbitrary tokens: a discrete swap at 50%. That is exactly the jump you are seeing.

@property supplies the missing type. The syntax descriptor tells the engine the value is a <length>, a <number>, a <color> or one of a handful of other grammars, and typed values interpolate numerically. Registration also unlocks two other behaviours worth knowing: the property is validated at computed-value time, so an invalid assignment falls back to initial-value instead of poisoning everything downstream; and the value can be animated by the compositor when its consumer is compositor-safe.

The same declaration, before and after registrationRegistration changes the interpolation rule, not the syntax of the property or its use.The same declaration, before and after registrationUnregisteredValue is an arbitrary token streamTransitions swap discretely at 50%An invalid value cascades as-isAlways resolved on the main threadSnaps instead of animatingRegistered with @propertyValue has a declared typeTransitions interpolate numericallyInvalid values fall back toinitial-valueCan be animated by the compositorInterpolates like a real property
Registration changes the interpolation rule, not the syntax of the property or its use.

Step-by-step resolution

Registering one property correctlyFour descriptors, three of which are effectively mandatory in production.Registering one property correctly1Name the property in an @property rule at the top level of the stylesheet.Registration is global, not scoped to a selector2Pick the narrowest syntax that fits: <length>, <number>, <color>,<percentage>, or a list form.Narrow types catch mistakes at assignment3Supply an initial-value that is valid for that syntax.Required for every syntax except '*'4Set inherits: false unless descendants genuinely need to read the value.Prevents a parent's value leaking into a child component5Feed the property into transform or opacity at the point of use.Keeps the resulting animation on the compositor
Four descriptors, three of which are effectively mandatory in production.

The inherits descriptor deserves more attention than it usually gets. A registered property with inherits: true behaves like color — every descendant sees the nearest ancestor’s value, which is right for a theme token and wrong for a per-component animation variable. With inherits: false it behaves like border: each element has its own value, defaulting to initial-value. Component-local animation state almost always wants the second.

Production code pattern

/* Intent: a card whose lift, tilt and glow are all driven by registered
   custom properties, so a single state change interpolates all three. */
@property --card-lift {
  syntax: '<length>';
  initial-value: 0px;
  inherits: false;
}

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

@property --card-glow {
  syntax: '<number>';   /* unitless opacity multiplier */
  initial-value: 0;
  inherits: false;
}

.card {
  /* The registered values feed compositor-safe properties only. */
  translate: 0 var(--card-lift);
  rotate: var(--card-tilt);
  transition:
    --card-lift 260ms cubic-bezier(0, 0, 0.2, 1),
    --card-tilt 260ms cubic-bezier(0, 0, 0.2, 1),
    --card-glow 200ms linear;
}

.card::after {
  /* A statically painted glow whose opacity is the animated value. */
  opacity: var(--card-glow);
}

.card[data-state='raised'] {
  --card-lift: -8px;
  --card-tilt: -0.6deg;
  --card-glow: 1;
}

/* Accessibility gate: the state still changes, without the travel. */
@media (prefers-reduced-motion: reduce) {
  .card {
    transition: --card-glow 160ms linear;
  }
  .card[data-state='raised'] { --card-lift: 0px; --card-tilt: 0deg; }
}

Rendering Impact: translate, rotate and a pseudo-element opacity — composite only. The registered properties are interpolated as typed values and substituted into compositor-safe consumers, so no frame touches layout or paint.

The pattern generalises: one attribute changes, several registered properties interpolate, and every one of them lands in a property the compositor can handle. That is the whole architecture described in mapping UI states to CSS custom properties, and registration is the piece that makes it interpolate rather than snap.

Syntax descriptors and what they enablePick the narrowest grammar the value can take; the wildcard buys nothing but permissiveness.Syntax descriptors and what they enableInterpolates?Typical use<length>NumericallyOffsets, radii, spacing driven bystate<number>NumericallyScale factors, opacity multipliers,progress<angle>NumericallyTilt, rotation, conic gradient stops<color>In the interpolation spaceThemed accents that fade betweenstates<percentage>NumericallyProgress bars, gradient positions'*' (any)Discrete swapValues that must not interpolate
Pick the narrowest grammar the value can take; the wildcard buys nothing but permissiveness.

Verification checklist

Constraints and trade-offs

  • Registration is global: two components registering the same property name with different syntaxes will fight, so namespace the names.
  • initial-value is mandatory for every syntax except '*', and it must be valid for that syntax at parse time.
  • A registered property still costs a style recalculation when it changes; the saving is that the consumer can be composited.
  • Registering a property whose consumer is a paint or layout property does not make that animation cheap — it only makes it smooth.
  • Older engines that ignore @property fall back to the discrete swap, so the resting state must be legible without the interpolation.

Frequently asked questions

Why does an unregistered custom property snap halfway through a transition?

Because the browser has no type information for it. Without a syntax descriptor the value is an arbitrary token stream, and the only interpolation the engine can define for arbitrary tokens is a discrete swap at the 50% mark. Registration supplies the type, which supplies the interpolation.

Does @property work in a stylesheet, or only in JavaScript?

Both. The CSS @property at-rule and the JavaScript CSS.registerProperty() call do the same thing; the at-rule is preferable because it lives with the styles it describes and applies before script runs.

Does registering a property make the animation compositor-safe?

It makes the property interpolatable. Whether the animation composites depends on what consumes the value: a registered number feeding a transform composites, and the same number feeding a box-shadow spread still paints every frame.


Registering from JavaScript, and why you usually should not

CSS.registerProperty() does the same job as the at-rule and takes the same descriptors. It exists because registration predates the CSS syntax, and because a component that generates property names at runtime has no other option.

For everything else the at-rule is better, for three reasons. It applies before any script runs, so a property is interpolatable on the very first style pass rather than from whenever the bundle executes. It lives next to the styles that use it, which means the type is visible to whoever is reading the component. And it is idempotent: re-registering the same name from CSS is harmless, whereas CSS.registerProperty() throws an InvalidModificationError on a duplicate, which turns a hot reload or a twice-imported module into a runtime error.

Where the scripted form genuinely fits, wrap it defensively:

// Intent: register generated property names once, tolerating re-entry.
function ensureRegistered(name, syntax, initialValue) {
  try {
    CSS.registerProperty({ name, syntax, inherits: false, initialValue });
  } catch (error) {
    if (error.name !== 'InvalidModificationError') throw error;  // already done
  }
}

The pattern to avoid entirely is registering a property in one place and assuming it in another. A component whose animation depends on a registration performed by a different module will snap instead of interpolating whenever the import order changes — a failure that looks like a CSS bug and is not.