Registered Properties and Type Safety: @property as a Contract for Design Tokens

A custom property with no registration is a string with a dollar sign's worth of guarantees. Anything can write to it, nothing validates it, and the first time a value is wrong you find out several layers away when a padding declaration silently disappears. The @property at-rule changes that: it attaches a grammar, an inheritance decision, and a guaranteed fallback to a token name, and the browser enforces all three. This page, part of the CSS Custom Properties Architecture section, treats registration as a type system rather than an animation trick — what the descriptors actually promise, and how those promises change failure behaviour.

The narrow scenario: a token layer used by many components, where a wrong value written in one theme file must not be able to break an unrelated rule two files away.


Why type a token, and when it is overhead

Unregistered custom properties are deliberately permissive. Their declared value is a sequence of tokens the parser accepts almost unconditionally, precisely so that --config: 1px solid red and --fragment: 40%, blue can exist. That permissiveness is what makes them useful as a general-purpose storage mechanism, and it is also what makes them dangerous as a contract: nothing checks the value until something tries to use it, at which point the failure surfaces in the consumer, not the producer.

Registering the property moves validation to the point of assignment. The browser parses each declared value against the declared grammar and rejects mismatches there, keeping the damage local. You also get two guarantees that unregistered properties cannot provide: the property always has a value of the declared type (there is no "unset variable" state to defend against), and the value is a computed value rather than a raw token stream, which is what makes it interpolatable — the subject of the separate page on animating custom properties with @property.

Registration is overhead when the property genuinely holds an arbitrary fragment — a shorthand you are splicing into a rule, a partial gradient stop list, a media-query-ish string. syntax: "*" exists for that case but buys you almost nothing beyond inherits and an initial value. It is also overhead in tiny stylesheets where the token is written once and read once. The value scales with the number of authors who can write to a token, not with the number of tokens.


What happens to an invalid value

The diagram traces a declared value through the type check. The important outcome is on the lower branch: a rejected value degrades the token, and never the declaration that consumed it.

Validation path for a registered custom property A declared value is checked against the syntax descriptor; a matching value is used, and a mismatching value falls back to the guaranteed initial value. What a registered property does with a bad value declared value --pad: red type check syntax: "<length>" matches the grammar computed value is used does not match falls back to initial-value Either way the rule reading the token still produces a usable value. Unregistered, the bad token would instead invalidate that rule.

A complete working implementation: a typed token layer

The sheet below defines four tokens with four different grammars, including an enumerated one, and a component that consumes them. Every deliberate mistake in the theme block is contained.

<article class="panel" style="--panel-density: cosy">
  <h2 class="panel__title">Deployment</h2>
  <p class="panel__body">Typed tokens keep a bad override from reaching this rule.</p>
</article>
/* A length. Inherits, because spacing scales should cascade to a subtree. */
@property --panel-pad {
  syntax: "<length>";
  inherits: true;
  initial-value: 16px;
}

/* A colour with a safe default that is legible on either theme surface. */
@property --panel-accent {
  syntax: "<color>";
  inherits: true;
  initial-value: #7aa2ff;
}

/* An enumerated token: only these three idents are accepted. */
@property --panel-density {
  syntax: "compact | cosy | roomy";
  inherits: false;
  initial-value: cosy;
}

/* A two-value list: exactly two lengths, space separated. */
@property --panel-offset {
  syntax: "<length>+";
  inherits: false;
  initial-value: 0px 2px;
}

.panel {
  padding: var(--panel-pad);
  border-inline-start: 3px solid var(--panel-accent);
  box-shadow: var(--panel-offset) 6px rgb(0 0 0 / 0.18);
  border-radius: 10px;
}

.panel__title { margin: 0 0 .5rem; font-size: 1.1rem; }
.panel__body { margin: 0; }

/* Density is an enum, so it can be matched exactly rather than parsed. */
@container style(--panel-density: compact) {
  .panel { --panel-pad: 8px; }
}

/* A theme file written by someone else. Two of these four are wrong. */
.theme-dark {
  --panel-pad: 24px;      /* valid: used */
  --panel-accent: nope;   /* invalid: --panel-accent stays #7aa2ff */
  --panel-offset: 0 3px;  /* invalid: 0 is a number here, not a length */
  --panel-density: roomy; /* valid: matches the enum */
}

Two of those overrides are rejected, and nothing else changes. --panel-accent keeps its registered initial value, so border-inline-start still resolves to a real colour instead of dropping out; --panel-offset keeps 0px 2px, so the box-shadow shorthand still parses. Delete all four @property blocks and the same theme file produces a panel with no left border and no shadow at all, because the invalid tokens propagate into those shorthands and invalidate them wholesale.

The --panel-offset case is the one that catches people: 0 is a valid length in most CSS contexts, but <length> in a syntax string does not accept a bare zero — write 0px. Registration turns that inconsistency into a caught error rather than a mysterious missing shadow.


The key technique: the syntax grammar is a small type language

syntax is not limited to a single type name. It is a restricted grammar with four constructs, and knowing them is what makes registration expressive enough to describe real tokens:

ConstructWritten asAccepts
Data type"<length>"one value of that type (<color>, <number>, <percentage>, <angle>, <time>, <image>, <length-percentage>, <integer>, <resolution>, <transform-function>, <custom-ident>, <string>, <url>)
Alternation"<length> | auto"either branch; also used for literal idents
Space list"<length>+"one or more values separated by spaces
Comma list"<color>#"one or more values separated by commas
Universal"*"any token sequence, with no type checking and no interpolation

Literal idents are written bare, which is how the enumerated --panel-density above works. Alternation is greedy in source order, so put the more specific branch first when two could both match.

The other two descriptors are equally load-bearing and both are mandatory — a @property rule missing syntax, inherits, or initial-value is dropped entirely, and its absence is silent. inherits decides how the token behaves when it is not set on the element: true walks up to the parent's computed value, false uses the initial value. Choose it by asking whether a nested instance of the component should pick up the outer instance's value; for state-like tokens the answer is almost always no. initial-value must itself parse against the grammar, and it is the guarantee that the token is never in an unresolvable state — the reason a consuming declaration can rely on it without a var() fallback argument. The single exception to the requirement is syntax: "*", where initial-value may be omitted; the property then behaves like an unregistered one that happens to have a fixed inheritance setting.


Variation: typing tokens that feed motion

Registration composes with the tokens that drive timing, because a <time> grammar rejects the classic mistake of writing a unitless duration.

@property --speed {
  syntax: "<time>";
  inherits: true;
  initial-value: 200ms;
}
@property --ease {
  /* An enum of names, resolved to real curves below. */
  syntax: "linear | standard | decelerate";
  inherits: true;
  initial-value: standard;
}

:root { --speed: 240ms; --ease: decelerate; }

.card {
  transition: transform var(--speed) var(--curve, cubic-bezier(.2, .8, .2, 1));
}

/* Map the enum to a curve. Style queries read the typed ident directly. */
@container style(--ease: decelerate) { .card { --curve: cubic-bezier(0, .6, .3, 1); } }
@container style(--ease: linear)     { .card { --curve: linear; } }

@media (prefers-reduced-motion: reduce) {
  :root { --speed: 1ms; }
}

A stray --speed: 240 anywhere in the codebase now resolves to the registered 200ms instead of producing an unparseable transition shorthand. The pattern pairs directly with fluid spacing tokens driving transition durations and with style queries on custom properties, which are considerably more reliable when the queried token has an enumerated grammar.


Browser support note

@property is supported in Chrome and Edge 85+, Safari 16.4+, and Firefox 128+, so the registration itself is safe to rely on across evergreen browsers as of mid-2026. The important thing about the fallback path is that it is not a cliff: in an engine without support the at-rule is ignored and every token reverts to unregistered behaviour, which means the values still apply and the components still render — you lose type checking and the guaranteed initial value, not the design. If a specific token's default genuinely must exist everywhere, back it up by writing the same value into a :root declaration and reading it with a var() fallback argument; that costs one line and covers both worlds.


FAQ

What happens when a registered property is given a value of the wrong type? The value is invalid at computed-value time for that custom property alone. A non-inheriting registered property then computes to its initial-value and an inheriting one takes the parent's computed value, so the declaration that consumed the token still resolves to something usable.

How is that different from an unregistered custom property? An unregistered property accepts almost any token sequence, so the mistake travels untouched into the consuming declaration and invalidates that instead. You lose the entire property that read the variable rather than just the bad token.

Can a syntax descriptor accept more than one type? Yes. A syntax string supports alternation with the pipe character, space-separated and comma-separated lists with the plus and hash multipliers, and literal keyword idents. The universal syntax * accepts anything but gives up both type checking and interpolation.

Should inherits be true or false for a design token? Set inherits: true for theme-wide tokens a whole subtree should pick up, such as colours and spacing scales. Set it to false for component-local or state-like values, so a nested instance cannot silently inherit its parent's state.


Related articles

More pages in the same section.