WSS
Web Specification Studio Home
On this page
BlogCSSPerformancePublished

CSS @property: The Complete Production Reference

What the introductions skip: computed-value validation mechanics, animation interpolation, inheritance cost model, and how PostCSS, Sass, and CSS code-splitting interact with registered custom properties.

This guide covers behavior of the @property at-rule and the CSS Properties and Values API Level 1 that isn’t covered by the standard introductions to the feature. It draws on the W3C specification’s algorithms rather than tutorial summaries.

The focus is what happens once a registered custom property has to survive a real build pipeline, a real animation timeline, and a real component tree:

  • Computed-value validation mechanics
  • The animation interpolation model
  • The inheritance and registration-scope cost model
  • Interactions with bundlers and preprocessors

Browser support: @property reached Baseline availability in July 2024 (Chrome/Edge 85+, Firefox 128+, Safari 16.4+). The JS equivalent, CSS.registerProperty(), shipped earlier, from Chrome 78. This guide assumes a registered-property-capable browser throughout.

It assumes familiarity with the basics: syntax, inherits, initial-value, and the general pitch that registration makes a custom property animatable and type-checked.

What is the CSS @property rule?

The @property CSS at-rule is part of the CSS Houdini umbrella of APIs. It allows developers to explicitly define a CSS custom property (variable), providing a type syntax, an inheritance behavior, and an initial fallback value. By registering a property with a specific type (like <color> or <length>), the browser understands how to parse and animate that value smoothly, something impossible with standard unregistered CSS variables which are treated as unparsed string tokens.

Computed-value-time validation

A registered property’s syntax is checked at computed-value time, not at parse time. Every introductory article states this. Fewer explain why. Fewer still explain exactly where the resulting invalid value surfaces.

Why validation is deferred

Browsers optimize CSS parsing by discarding invalid declarations immediately and keeping only the last valid one per property per rule. This works because a standard property’s accepted syntax never changes during a page’s lifetime.

A custom property’s registered syntax can change. A later @property rule, or a registerProperty() call, can retroactively redefine what’s valid for a name that already has declarations sitting in already-parsed stylesheets.

Syntax-checking at parse time would require one of two things:

  1. Storing every declaration ever seen, including ones currently invalid, in case registration later makes them valid.
  2. Re-parsing the whole document whenever a registration changes.

Both are more expensive than deferring the check to computed-value time, which happens per element and per frame regardless.

Where the invalid value actually surfaces

This is the part that’s easy to get wrong when debugging. Consider an unregistered custom property used with an invalid override:

.thing {
  --my-color: green;
  --my-color: url("not-a-color");
  color: var(--my-color);
}

Both declarations of --my-color parse successfully. A custom property accepts almost any token stream at parse time, so the second silently overrides the first.

color: var(--my-color) then substitutes url("not-a-color"), which is not a valid <color>. That makes color itself invalid at computed-value time, and color falls back to its own initial value’s resolution: inherit.

Now register --my-color:

@property --my-color {
  syntax: "<color>";
  inherits: false;
  initial-value: black;
}

The parsing doesn’t change. What changes is which property becomes invalid at computed-value time.

Now it’s --my-color that fails validation and resets to its own registered initial-value (black). color receives a valid <color>, black, instead of failing itself and falling back to inherit.

Note: Registering a property doesn’t just add a fallback. It moves the point of failure. An invalid value that used to fail the consuming property, and cascade to that property’s inherited or initial value, now fails the custom property itself. The consuming property gets a valid, predictable value instead. That’s the actual mechanism behind the commonly cited benefit of a fallback that never disappears.

var() fallbacks are validated even when unused

A var() reference to a registered custom property may supply a fallback: var(--x, fallback). The fallback value must also match the registered syntax. This check runs regardless of whether the fallback is actually needed.

@property --gap {
  syntax: "<length>";
  inherits: false;
  initial-value: 8px;
}

.card {
  /* --gap is set and valid. The fallback is never used at runtime. */
  padding: var(--gap, not-a-length);
}

Warning: padding here is invalid at computed-value time, even though --gap is defined and perfectly valid. The unused fallback not-a-length doesn’t parse as <length>, and that alone is enough to invalidate the declaration. A fallback you never expect to hit still has to type-check.

This surfaces most often in generated or templated CSS, where a fallback is inserted defensively, just in case the variable isn’t set, without checking it against the property’s registered syntax.

Computed values, var() substitution, and stylesheet-relative URLs

The computed value of a registered property depends on its syntax:

SyntaxComputed value behavior
<length>, <angle>, <time>, and similar numeric typesCanonicalized to a standard unit for that type
<percentage>Never resolved against anything
<color>Resolved per the CSS Color specification
<custom-ident>, bare idents, *Computed as specified, unchanged
<url>Resolved differently depending on registration; see below

<url> values compute differently depending on registration, and this has a direct consequence for anything that concatenates stylesheets.

var() substitutes the computed value, not the source tokens

For an unregistered custom property, var() substitutes the original token sequence exactly as written.

For a registered property, var() substitutes the computed value instead, re-serialized and re-tokenized.

The spec’s own example: given --x registered as <length> and set to 8em on an element with font-size: 10px, var(--x) in another declaration produces the token 80px, not 8em.

This matters for <url> specifically, because relative URLs resolve against the base URL of the stylesheet they’re computed in.

/* /style/foo/foo.css */
div {
  --url-foo: url("foo.png"); /* resolves against /style/foo/ */
}
/* /style/bar/bar.css */
div {
  --url-bar: url("bar.png"); /* resolves against /style/bar/ */
}
<link href="/style/foo/foo.css" rel="stylesheet">
<link href="/style/bar/bar.css" rel="stylesheet">
<div style="background-image: var(--url-foo), var(--url-bar);"></div>

If --url-foo and --url-bar are registered with <url> syntax, each resolves against its own source stylesheet’s base URL. That gives /style/foo/foo.png and /style/bar/bar.png, regardless of where the resulting value is consumed.

Unregistered, both would substitute as literal relative tokens into the consuming stylesheet, and resolve against that stylesheet’s base URL instead.

Note: This is a real divergence between development and production for any build that inlines or concatenates CSS. Two separately authored stylesheets in different directories, each defining a <url>-typed custom property with a relative URL, resolve those URLs against their own original file locations when served unbundled. Once a bundler concatenates them into a single output file, the browser only has one file left to reason about as the origin. The per-file base URL distinction the spec relies on no longer exists. Whether this changes behavior depends on how the bundler rewrites relative URLs during concatenation. Teams that see <url>-typed registered properties resolve correctly in dev, with multiple linked stylesheets, and incorrectly in a concatenated production build should look here first.

Dependency cycles via relative length units

This is the least documented corner of the spec. It only affects registered <length> or <length-percentage> properties that use font-relative or line-height-relative units.

The specification adds dependency-graph edges for registered properties using these units:

Unit(s)Edge added
em, ex, cap, ch, ic, lhTo the element’s own font-size
lhAlso to the element’s own line-height
rem, rlhTo the root element’s font-size
rlhAlso to the root element’s line-height

These edges exist so the engine can detect a dependency cycle.

If a registered length-typed custom property is used to compute font-size on the same element, and that custom property’s own value is expressed in a unit relative to that same font-size, the cycle is caught. The property resolves as if unset had been specified.

CSS.registerProperty({
  name: "--my-font-size",
  syntax: "<length>",
  initialValue: "0px",
  inherits: false,
});
div {
  --my-font-size: 10em;
  font-size: var(--my-font-size);
}

--my-font-size is in em, which depends on font-size. font-size is set from --my-font-size. That’s a cycle.

Per spec, font-size behaves as if unset was specified. It resolves to its inherited value, silently ignoring the 10em declaration entirely.

Warning: This is easy to hit by accident in fluid-typography setups that pipe a clamp() or a calc() expression through a registered custom property and then feed it back into font-size on the same selector, if any unit in that chain is font-relative. The failure mode is silent. There’s no console warning, no invalid-at-computed-value-time report, just font-size quietly ignoring the custom property and using the inherited value instead.

The fix: keep the registered property in absolute units when it’s going to feed back into font-size on the same element. Two safe options:

  • px
  • rem or rlh, which reference the root element’s font-size rather than the element’s own

Animation interpolation mechanics

Registered custom properties interpolate by computed value, according to the type they parsed as. This is the general by-computed-value animation type from Web Animations, applied per the property’s registered syntax rather than to any built-in CSS property.

List syntaxes interpolate index-by-index

A property registered with a multiplied syntax, such as <color>+ or <length>#, interpolates as a simple list. Each component is matched to the corresponding component in the other keyframe by index.

@property --stops {
  syntax: "<color>#";
  inherits: false;
  initial-value: red, blue;
}

.bar {
  --stops: red, blue;
  background: linear-gradient(to right, var(--stops));
  transition: --stops 1s;
}

.bar:hover {
  --stops: green, yellow, purple; /* three items, not two */
}

The number of components doesn’t match between the two keyframe values: two colors versus three. Interpolation can’t proceed component by component.

Note: Per the animation-type rules for by-computed-value interpolation, a mismatch in component count or type causes the whole property to combine as discrete instead. There’s no smooth blend, just an instantaneous swap partway through the transition (at the 50% mark for a two-keyframe case). Visually this looks like the gradient jumping rather than crossfading. It’s easy to misdiagnose as a browser bug rather than a keyframe-authoring mistake, especially when the list lengths are generated dynamically and only mismatch under specific data.

transform-list and transform-function are a documented exception

Every other syntax type interpolates strictly component by component. Values that parsed as <transform-function>, <transform-list>, or <transform-function>+ are the exception. They interpolate the same way the transform property does: through matrix decomposition and recomposition, not naive per-component blending.

A registered property with syntax <transform-function># (comma-separated) interpolates as a simple list first. After that, each list item interpolates using the transform interpolation rules.

The universal syntax is never animatable

A property registered with syntax: "*" computes the same way an unregistered custom property does: the specified value with var() references substituted, or the guaranteed-invalid value.

There’s no defined type to interpolate against, so animations and transitions on a *-syntax property fall back to discrete combination regardless of what the value looks like.

If a property needs to animate smoothly, it needs a concrete syntax. Registering with * still gets the fallback-value and computed-value-time-validation benefits, but not the animation benefit, which is usually the entire reason people reach for @property in the first place.

Registration scope and the inheritance cost model

Registration is global, not shadow-scoped

Custom property registrations are not scoped to a tree. Not to a shadow root, not to a stylesheet, not to a @scope block. All registrations, wherever they’re declared, share a single registration map per document.

The spec’s rationale: elements can exist in multiple tree scopes simultaneously. A shadow host is styled from both the outer document and, via :host, the shadow tree itself. There’s no unambiguous way to decide which tree’s registration should apply to a given element if registrations could differ per tree.

Two rules govern conflicts:

  1. If two valid @property rules register the same name, the last one in stylesheet order wins, silently, with no warning or error.
  2. A registerProperty() JS call wins over any @property rule for the same name, regardless of the order either was encountered in the document.

Warning: This means a component library and a host application can register the same custom property name with different, incompatible syntaxes, and the result depends entirely on load order, not on which registration was intended for that context. Lea Verou, documenting this in the context of design-system defaults, describes the result as @property failing silently, with the last one winning. A component can silently inherit the host page’s initial value, or vice versa, with no indication anything went wrong.

The practical mitigation is namespacing. Give component-internal registered properties a name unlikely to collide, by including a project or component-specific prefix rather than a generic name like --color or --size.

Enumerating and auditing registrations in a live document

Because conflicts are silent, detecting them requires walking the CSSOM directly. There’s no warning surface to rely on.

/**
 * Scans all accessible stylesheets for @property rules and reports
 * any name registered more than once, along with which registration
 * actually wins (last one in document order, unless JS registration
 * for the same name exists, since that always wins and isn't visible
 * here).
 */
function auditPropertyRegistrations() {
  const seen = new Map(); // name -> [{ syntax, inherits, initialValue, sourceHref }]

  for (const sheet of document.styleSheets) {
    let rules;
    try {
      rules = sheet.cssRules; // throws for cross-origin stylesheets without CORS
    } catch {
      console.warn(`Skipped inaccessible stylesheet: ${sheet.href}`);
      continue;
    }

    for (const rule of rules) {
      if (rule instanceof CSSPropertyRule) {
        const entry = {
          syntax: rule.syntax,
          inherits: rule.inherits,
          initialValue: rule.initialValue,
          sourceHref: sheet.href ?? "(inline)",
        };
        if (!seen.has(rule.name)) seen.set(rule.name, []);
        seen.get(rule.name).push(entry);
      }
    }
  }

  for (const [name, registrations] of seen) {
    if (registrations.length > 1) {
      console.warn(
        `${name} registered ${registrations.length} times. ` +
        `Winner (last in document order): ${JSON.stringify(registrations.at(-1))}`,
      );
      console.table(registrations);
    }
  }

  return seen;
}

Run this in the console, or wire it into a build-time smoke test against a rendered page, to catch cross-team or cross-library naming collisions before they show up as a mysteriously wrong initial value in production.

It won’t catch a CSS.registerProperty() call, since JS-registered properties aren’t exposed as CSSPropertyRule objects in document.styleSheets. Cross-reference against known JS registration call sites separately if the codebase mixes both registration methods.

The inheritance cost model

The spec states that registering a property must not affect the cascade in any way. Which value wins at a given element is decided exactly as it would be for an unregistered custom property.

What registration changes is what happens after the cascade, at computed-value time, including how the inherits flag is resolved.

An unregistered custom property always inherits, unconditionally. That’s part of the base custom-properties spec, not something authors can turn off.

A registered property with inherits: false behaves like most standard, non-inherited CSS properties (width or border, for example): if it isn’t set on an element or an ancestor, the element uses the registration’s initial-value directly, without walking up the tree to check for an inherited value at all.

This has a direct, spec-stated consequence for animations:

  • Re-registering a property, or changing its registration, can change its computed value.
  • A CSS transition is defined in terms of a computed-value change.
  • Altering a registration mid-lifecycle can therefore start or interrupt a running transition on its own, with no authored CSS change involved at all.

Toggling a property between inherits: true and inherits: false isn’t just a bookkeeping change. It can retroactively alter what value an element resolves to.

Build tooling and preprocessors

CSS code-splitting and last-wins don’t compose safely

The registration spec’s tie-breaking rule (last valid @property rule in document order wins) assumes a document with a fixed, deterministic stylesheet order. That assumption doesn’t hold for an app that code-splits CSS by route and loads chunks asynchronously.

Bundler-driven CSS chunk loading is documented, independently of @property, as not guaranteeing that chunks apply in source-import order. Chunk load and application order can depend on network timing and route-visit order rather than the order routes were declared in the source.

Combine that with the spec’s document-order tie-break, and a real risk appears:

  • An app registers the same custom property name in two different route-level CSS chunks, even with intentionally identical syntax, for consistency.
  • The registration winner can depend on which route the user visited first in that session.
  • The winner is not determined by source order, and nothing in the authored CSS shows this.

Note: This isn’t a theoretical concern specific to @property. It follows directly from combining two separately documented, separately reasonable behaviors: CSS chunk load-order nondeterminism, and last-registration-wins. The mitigation is the same namespacing discipline as the shadow DOM case. Either register shared custom properties once, in a chunk that’s guaranteed to load first (an entry chunk, not a route chunk), or avoid registering the same name in more than one chunk at all.

PostCSS and Sass pass @property through as an opaque at-rule

Sass and PostCSS both treat @property as an unrecognized at-rule with a declaration block, which they preserve syntactically without evaluating its meaning. That’s usually fine, with two practical caveats.

Sass interpolation inside the syntax string works, but only at compile time.

syntax: "#{$my-type}" is valid Sass and interpolates correctly. There’s no variable behavior at runtime. Whatever the Sass compiler resolves $my-type to at build time is what ships, permanently, in that build.

Minifiers that deduplicate identical-looking rules need checking against @property specifically.

Two @property blocks for the same name are not interchangeable even if their descriptor text is byte-identical. Position in the merged output determines which one the last-wins rule selects. A minifier that reorders or hoists rules for compression can change which registration applies, even without changing what either registration’s text says.

Neither Sass nor stock PostCSS ships special handling for @property. Tooling built specifically for the Properties and Values API, such as PostCSS plugins that transform @property into an equivalent registerProperty() JS call for older-browser fallback, should be treated as another registration source in the audit described above. It introduces a JS-side registerProperty() call that, per the spec’s precedence rule, silently wins over any CSS @property rule for the same name.

Production debugging checklist

When a registered property appears to silently reset to its initial value, or an animation involving it snaps instead of transitioning, work through this order:

  1. Check the Computed panel, not the Styles panel, in DevTools. Because validation happens at computed-value time, an invalid declaration still displays normally in the Styles pane. It parsed fine. The Computed panel shows the actual resolved value, which is where an unexpected fallback to initial-value becomes visible.
  2. Run the registration audit script above to rule out a duplicate registration with conflicting syntax or initial-value overriding the one you expect, especially across separately loaded chunks or a mix of design-system and app-level CSS.
  3. Check var() fallbacks against the registered syntax directly, including fallbacks that should never be reached in normal operation. An invalid unused fallback invalidates the whole declaration regardless of whether it’s used.
  4. For length-typed properties that feed into font-size on the same element, check every unit in the chain for a font-relative unit (em, rem, lh, rlh, and the rest) that could create a dependency-cycle edge back to that same font-size.
  5. For list-syntax properties used in animations or transitions, confirm the number of comma- or space-separated components matches across every keyframe or state being transitioned between. A mismatch silently downgrades to discrete combination with no console warning.
  6. If a url-typed property behaves differently in production than in local dev, check whether the build concatenates stylesheets that were previously separate files. The per-source-stylesheet base URL resolution the spec defines only makes sense when those stylesheets remain distinct.

See also

From the team at

We build digital products and explore the modern web standards behind them.

Related posts