@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.
Prerequisites Link to this section
- Familiarity with what registration does, covered in registering design tokens with @property.
- A token file with
$typevalues, so registrations can be generated rather than hand-written. - A browser that supports
@propertyfor 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.
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.
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.
Related Link to this section
- Houdini @property Type-Safe Tokens — the parent area, including when registration is worth the cost
- Registering Design Tokens with the @property Rule — the four descriptors and how registration behaves
- Animating Design Tokens with Typed Custom Properties — what typing enables once the registration is in place