@property Syntax Reference for Design Tokens

Part of Houdini @property Type-Safe Tokens. This page is the lookup table: which syntax descriptor to use for each kind of design token, what each one accepts, and the registration to copy.

Design token types mapped to syntax descriptors Six common design token categories on the left mapped to the at-property syntax descriptor each requires on the right, with the composite types marked as needing decomposition. colour token "<color>" spacing, radius, size "<length>" or "<length-percentage>" line height, opacity, ratio "<number>" duration, easing "<time>" · easing needs "*" shadow, typography set no single syntax — decompose into parts
Most token types map to a single descriptor. The composite types in the last row are the interesting case, and the one the specification deliberately does not cover.

Prerequisites Link to this section

  • Familiarity with what registration does, covered in registering design tokens with @property.
  • A token file with $type values, so registrations can be generated rather than hand-written.
  • A browser that supports @property for testing; unsupported engines ignore the rule silently, which makes a mistake hard to notice.

The Syntax Descriptors, by Token Type Link to this section

Colour tokens Link to this section

@property --ds-color-action-primary {
  syntax: "<color>";
  inherits: true;
  initial-value: #2563eb;
}

Accepts every colour notation the engine supports: hex, rgb(), hsl(), oklch(), named colours, and transparent. It does not accept currentColor as an initial value in all engines, so prefer a literal. Registration is what makes a colour transition interpolate rather than jump, which is the main reason to register colour tokens at all.

Length tokens — spacing, radius, border width Link to this section

@property --ds-space-inset-md {
  syntax: "<length>";
  inherits: false;
  initial-value: 16px;
}

<length> accepts px, rem, em, ch, viewport units and calc() expressions that resolve to a length. It rejects a bare 0, which is a genuine surprise: 0 is a <number>, not a <length>, so --ds-space-none: 0 fails validation against <length> and falls back to the initial value. Write 0px.

Use <length-percentage> where a percentage is legitimate — a width or an inset — and plain <length> everywhere a percentage would be meaningless, because the narrower type catches more mistakes.

Number tokens — line height, opacity, scale ratios Link to this section

@property --ds-line-height-body {
  syntax: "<number>";
  inherits: true;
  initial-value: 1.5;
}

<number> accepts integers and decimals with no unit. It is the right type for unitless line heights, opacity values and modular scale ratios. Registering these enables interpolation of things that otherwise snap — an opacity token animating smoothly, for instance.

Percentage and angle tokens Link to this section

@property --ds-opacity-disabled { syntax: "<percentage>"; inherits: true; initial-value: 40%; }
@property --ds-gradient-angle   { syntax: "<angle>";      inherits: false; initial-value: 135deg; }

<angle> accepts deg, rad, grad and turn, and registering it is what makes a rotating gradient animation possible — an unregistered angle in a linear-gradient() cannot be transitioned.

Time and easing tokens Link to this section

@property --ds-duration-fast { syntax: "<time>"; inherits: true; initial-value: 150ms; }

/* Easing functions have no type. Use the universal syntax. */
@property --ds-ease-standard { syntax: "*"; inherits: true; }

There is no <easing-function> descriptor. Registering an easing token with "*" opts out of typing entirely, which gives you inheritance control and nothing else — no validation, no interpolation, no initial value. That is usually still worth having for inherits: false, and it documents that the omission was deliberate.

Keyword tokens — a fixed set of allowed values Link to this section

@property --ds-density {
  syntax: "compact | comfortable | spacious";
  inherits: true;
  initial-value: comfortable;
}

A pipe-separated list of custom identifiers is the closest CSS gets to an enum. Any value outside the list is invalid and falls back to the initial value, which makes this the single most useful descriptor for catching typos in a token that drives a variant.

Syntax combinators and what each one accepts Four combinator forms — pipe alternation, plus for space-separated lists, hash for comma-separated lists, and the universal asterisk — each with an example and its effect on validation. a | b — alternation: "<length> | auto" accepts either form; the first matching branch wins a+ — space-separated list: "<length>+" accepts "4px 8px 12px" — useful for a padding shorthand token a# — comma-separated list: "<color>#" accepts "red, blue" — useful for a gradient stop list * — universal: no validation, no interpolation, inheritance control only
The combinators cover most composite cases without needing a dedicated type. A shadow token is the notable exception they cannot express.

Composite Tokens Have No Single Syntax Link to this section

The DTCG specification defines composite types — shadow, typography, border, transition — as objects with several fields. CSS has no matching descriptor, and "*" gives up all the benefits. The workable approach is to decompose.

/* Register the parts, compose the value. */
@property --ds-shadow-2-x     { syntax: "<length>"; inherits: false; initial-value: 0px; }
@property --ds-shadow-2-y     { syntax: "<length>"; inherits: false; initial-value: 4px; }
@property --ds-shadow-2-blur  { syntax: "<length>"; inherits: false; initial-value: 8px; }
@property --ds-shadow-2-color { syntax: "<color>";  inherits: false; initial-value: rgb(0 0 0 / 0.12); }

.card {
  box-shadow:
    var(--ds-shadow-2-x) var(--ds-shadow-2-y)
    var(--ds-shadow-2-blur) var(--ds-shadow-2-color);
}

Why this works: each part is typed, validated and independently interpolable, so a hover state that deepens a shadow animates smoothly instead of snapping. The cost is four token names where the design tool has one, which is a real ergonomic loss — mitigated by generating the four from the single composite definition rather than authoring them by hand.

The same decomposition applies to typography sets: font size, line height and letter spacing registered separately, composed at the point of use. This is covered in more depth in tokenizing line-height and letter-spacing pairs.

Generating Registrations From the Token File Link to this section

// scripts/emit-property-rules.mjs
const SYNTAX = {
  color: '<color>',
  dimension: '<length>',
  number: '<number>',
  duration: '<time>',
  fontWeight: '<number>',
  // Composites are decomposed upstream; anything unmapped opts out.
};

export function registration(name, token) {
  const syntax = SYNTAX[token.$type] ?? '*';
  const inherits = token.$extensions?.['com.ds.inherits'] ?? true;
  const lines = [
    `@property ${name} {`,
    `  syntax: "${syntax}";`,
    `  inherits: ${inherits};`,
  ];
  if (syntax !== '*') lines.push(`  initial-value: ${token.$value};`);
  lines.push('}');
  return lines.join('\n');
}

Why this works: the mapping table is the only place type knowledge lives, so adding a new token type is one line. Omitting initial-value when the syntax is "*" is required — a universal-syntax registration with an initial value is invalid and the whole rule is dropped.

Validation outcomes for a typed custom property Three outcomes when a value is assigned to a registered property: a valid value applies, an invalid value falls back to the initial value, and an unregistered property passes the invalid value through to descendants. valid value --ds-space-inset-md: 24px → applies, and interpolates on transition invalid value, registered --ds-space-inset-md: 0 → rejected, falls back to initial-value 16px invalid value, unregistered --ds-space-inset-md: 0 → stored as a token stream, inherits, breaks calc() downstream
The middle row is the whole argument for registration. A bare zero in a length token is the most common way a design system encounters it.

Choosing Between a Narrow and a Wide Syntax Link to this section

For several token types more than one descriptor would work, and the choice is a trade between validation strength and flexibility. The general principle is to pick the narrowest type that accepts every value the token legitimately needs, and to widen only when a real value is rejected.

A spacing token illustrates the trade. "<length>" rejects percentages, which is usually correct for inset spacing — a percentage padding resolves against the container’s width and produces surprising results in a vertical direction. "<length-percentage>" accepts both, which is right for a token used in a width or an inset. Registering the spacing scale as <length> catches an entire class of mistake at the cost of forcing an explicit exception where a percentage is genuinely wanted, and that exception is worth making visible.

Alternation deserves particular care because the branches are tried in order. "<length> | auto" accepts 16px and auto, which is exactly what a width token needs. But "<number> | <length>" will match 0 as a number and never reach the length branch, so a downstream calc() expecting a length receives a unitless value. Ordering the branches from most specific to least avoids most of these.

Finally, resist the temptation to reach for "*" when a value proves awkward to type. The universal syntax is correct for values CSS genuinely has no type for — easing functions, arbitrary shorthand strings — and it is a silent opt-out everywhere else. A token file where a third of the registrations use "*" has the cost of registration with almost none of the benefit, and nothing in the build will point that out.

Troubleshooting Link to this section

Symptom Likely cause Fix
A registration appears to do nothing initial-value is missing on a typed syntax All three descriptors are required unless the syntax is "*"
0 is rejected by a <length> property A bare zero is a <number>, not a <length> Write 0px, or use "<length> | 0" if bare zeros must be accepted
currentColor fails as an initial value It is not a computationally independent value Use a literal colour as the initial value
A keyword token accepts anything The syntax is "*" rather than the pipe list Enumerate the allowed identifiers explicitly
The rule is ignored entirely An invalid descriptor makes the whole rule invalid Check every descriptor; a single typo discards the registration silently

Where the Registration Rules Should Live Link to this section

Registrations are generated CSS, and they belong in the same artefact as the tokens they describe — emitted by the compiler, shipped alongside variables.css, never hand-edited. Two practical consequences follow from treating them that way.

The first is ordering. A registration must be parsed before the property is used for the type to apply, so the registration file has to load first. Putting the rules at the top of the token file rather than in a separate one removes the ordering question entirely, at the cost of a slightly larger token artefact.

The second is duplication across bundles. If several bundles each include the registrations, re-registering the same property is legal — the last registration wins — but it means a bundle with an outdated registration can silently override a newer one. Emitting registrations exactly once, in the base token bundle that everything else depends on, avoids a class of bug that is genuinely difficult to diagnose from the rendered result.

Migration Note Link to this section

Registering an existing token set is best done in one generated file rather than by hand, and it is worth doing in two passes. The first pass registers only the tokens that are animated somewhere, because those are the ones where registration changes behaviour immediately and visibly. The second registers everything else for the validation benefit.

Watch for values that were previously tolerated and now fall back. Bare zeros are the most common, followed by values with a trailing comment or an unexpected unit. Each of these was previously a token stream that happened to work in most contexts and now becomes a validation failure with a visible fallback — which is the point, but it does mean the second pass should be shipped where it can be reviewed rather than late on a Friday.