Registering Design Tokens with the @property Rule
Part of Houdini @property Type-Safe Tokens. This guide walks through registering a complete design-token set using @property — choosing the right syntax descriptor per token category, configuring inherits and initial-value, generating registration rules from a token JSON in the build pipeline, and progressively enhancing for browsers that do not yet support Houdini.
tokens.json, infers the correct syntax per category, and emits both @property rules and plain custom-property declarations — giving Houdini-capable browsers type safety while plain-variable browsers keep working.Prerequisites
Before following these steps, make sure:
- Your token set is already authored in a JSON file following a flat or nested primitive→semantic structure (e.g. the output of a Style Dictionary pipeline or Tokens Studio export).
- Node.js 18+ is available in your build environment — the generation script uses the native
fs/promisesAPI. - Your CSS entry point is bundled by a tool that supports
@propertypassthrough (PostCSS, Vite, esbuild, or plain concatenation all work — do not need transforms). - Browsers you must support without degradation: any browser that does not understand
@propertymust still receive correct visual output through the plain--varfallback. Verify your baseline using the@supportscheck in step 6. - You understand the three-tier token hierarchy (primitives, semantics, component aliases) described in the Houdini @property Type-Safe Tokens reference. The registration step sits between token compilation and component consumption.
Step-by-Step Implementation
Step 1 — Audit your token JSON and assign syntax categories
Open your token JSON and map every token to one of the CSS @property supported syntax keywords. This is a manual or scripted audit you do once; from then on the build derives syntax automatically from a category field or the token’s value shape.
{
"color": {
"action-primary": { "value": "#7c3aed", "syntax": "<color>" },
"text-primary": { "value": "#0f172a", "syntax": "<color>" }
},
"spacing": {
"scale-4": { "value": "4px", "syntax": "<length>" },
"scale-8": { "value": "8px", "syntax": "<length>" },
"scale-16": { "value": "16px", "syntax": "<length>" }
},
"opacity": {
"subtle": { "value": "0.08", "syntax": "<number>" }
},
"duration": {
"fast": { "value": "120ms", "syntax": "<time>" },
"normal": { "value": "250ms", "syntax": "<time>" }
},
"z-index": {
"overlay": { "value": "400", "syntax": "<integer>" }
},
"easing": {
"standard": {
"value": "cubic-bezier(0.4, 0, 0.2, 1)",
"syntax": "<custom-ident>"
}
}
}
Why this works: @property enforces syntax at computed-value time. A browser that receives an invalid value for a <color> property substitutes initial-value instead of propagating a broken value through the cascade — which is exactly the safety guarantee you want from a typed token system. Easing tokens use <custom-ident> because cubic-bezier(…) is not a <length> or <color>; it is a function value and must be treated as an opaque string for registration purposes.
Step 2 — Write the @property generation script
Create a build script at scripts/generate-property-rules.mjs. It reads the token JSON, walks every token, and emits one @property at-rule per token followed by all :root custom-property declarations.
// scripts/generate-property-rules.mjs
import { readFile, writeFile } from 'fs/promises';
import { join } from 'path';
const tokens = JSON.parse(
await readFile(new URL('../tokens/tokens.json', import.meta.url), 'utf8')
);
const PREFIX = '--ds';
const lines = [];
function walk(obj, segments) {
for (const [key, val] of Object.entries(obj)) {
if (val && typeof val === 'object' && 'value' in val) {
const name = [PREFIX, ...segments, key].join('-');
const syntax = val.syntax ?? '*';
const inherits = val.inherits ?? 'true';
const initial = val.value;
lines.push(
`@property ${name} {`,
` syntax: "${syntax}";`,
` inherits: ${inherits};`,
` initial-value: ${initial};`,
`}`
);
} else if (val && typeof val === 'object') {
walk(val, [...segments, key]);
}
}
}
walk(tokens, []);
// Emit :root block after all @property rules
lines.push('', ':root {');
function walkRoot(obj, segments) {
for (const [key, val] of Object.entries(obj)) {
if (val && typeof val === 'object' && 'value' in val) {
const name = [PREFIX, ...segments, key].join('-');
lines.push(` ${name}: ${val.value};`);
} else if (val && typeof val === 'object') {
walkRoot(val, [...segments, key]);
}
}
}
walkRoot(tokens, []);
lines.push('}');
await writeFile(
join(process.cwd(), 'src/tokens/tokens.css'),
lines.join('\n') + '\n'
);
console.log(`Wrote ${lines.filter(l => l.startsWith('@property')).length} @property rules.`);
Why this works: Separating the @property blocks from the :root declarations is intentional. The spec requires @property to be a top-level rule — not nested inside :root or any selector. The script places all registrations first, then declares values in :root so browsers process the type information before they encounter the first use.
Step 3 — Configure inherits correctly per token category
The inherits descriptor controls whether the typed custom property participates in CSS inheritance. Set it wrong and you will spend hours debugging values that do not cascade down as expected (or cascade when they should not).
// Extend the walk function: derive inherits from category
const INHERITS_BY_CATEGORY = {
color: 'true', // colors should flow down to children
spacing: 'false', // spacing tokens are layout-local; do not inherit
opacity: 'true',
duration: 'false', // animation durations should not bleed into children
'z-index':'false',
easing: 'false',
};
function inferInherits(segments) {
const category = segments[0];
return INHERITS_BY_CATEGORY[category] ?? 'true';
}
Then use inferInherits(segments) in walk instead of reading val.inherits. This keeps the token JSON clean of infrastructure concerns while making the build script the single source of truth for inheritance decisions.
Why this works: Color tokens with inherits: true mirror how color itself cascades — a parent that sets --ds-color-text-primary naturally propagates to nested elements without explicit re-declaration. Spacing and timing tokens with inherits: false are structural; they describe layout or motion contracts for a specific component, not a heritable style signal.
Step 4 — Set initial-value defensively
Every @property registration must supply an initial-value that is valid for its syntax. Omitting it causes the browser to throw a DOMException during parsing and discard the registration silently in some engines, loudly in others.
/* Generated output — sample of what tokens.css should look like */
@property --ds-color-action-primary {
syntax: "<color>";
inherits: true;
initial-value: #7c3aed;
}
@property --ds-spacing-scale-4 {
syntax: "<length>";
inherits: false;
initial-value: 4px;
}
@property --ds-opacity-subtle {
syntax: "<number>";
inherits: true;
initial-value: 0.08;
}
@property --ds-duration-fast {
syntax: "<time>";
inherits: false;
initial-value: 120ms;
}
@property --ds-z-index-overlay {
syntax: "<integer>";
inherits: false;
initial-value: 400;
}
:root {
--ds-color-action-primary: #7c3aed;
--ds-spacing-scale-4: 4px;
--ds-opacity-subtle: 0.08;
--ds-duration-fast: 120ms;
--ds-z-index-overlay: 400;
}
Why this works: The initial-value is the fallback the browser substitutes when a component assigns an invalid value to the property — for example, --ds-color-action-primary: 42px would silently revert to #7c3aed rather than producing an unparseable value. This is the core safety guarantee of typed custom properties.
Step 5 — Wire the script into your build
Add the generation step before your main CSS bundle so the emitted tokens.css is always fresh.
{
"scripts": {
"tokens:generate": "node scripts/generate-property-rules.mjs",
"build:css": "postcss src/main.css -o dist/main.css",
"build": "npm run tokens:generate && npm run build:css",
"dev": "npm run tokens:generate && postcss src/main.css -o dist/main.css --watch"
}
}
Import tokens.css at the top of your CSS entry point:
/* src/main.css */
@import "./tokens/tokens.css";
@import "./components/button.css";
@import "./components/card.css";
Why this works: Running tokens:generate before PostCSS means the build never ships stale @property rules. If a token is removed from JSON, its @property rule disappears from the next build — no dangling registrations accumulate.
Step 6 — Add progressive enhancement for unsupported browsers
Browsers that do not support @property will parse the at-rules, fail silently, and fall through to the plain --var declarations in :root. That is enough for basic behavior, but animated tokens need an explicit @supports guard to prevent broken transitions.
/* components/button.css */
.btn--primary {
/* Works in all browsers — typed or not */
background-color: var(--ds-color-action-primary, #7c3aed);
transition: background-color var(--ds-duration-fast, 120ms) var(--ds-easing-standard, ease);
}
/* Houdini-only enhancement: animated opacity on the typed token */
@supports (syntax: "<color>") {
.btn--primary {
transition:
background-color var(--ds-duration-fast) var(--ds-easing-standard),
opacity var(--ds-duration-fast) var(--ds-easing-standard);
}
}
Why this works: @supports (syntax: "<color>") is a Houdini feature query. Only browsers that implement @property parse the inner block. Plain-var browsers ignore it. This pattern is the same progressive-enhancement model used in the companion guide on animating design tokens with typed custom properties.
Step 7 — Register tokens programmatically via CSS.registerProperty (optional)
For tokens that vary at runtime — such as tenant-supplied brand colors loaded from an API — register them in JavaScript instead of or in addition to the CSS at-rule.
// src/tokens/register-runtime-tokens.js
const RUNTIME_TOKENS = [
{
name: '--ds-color-brand-primary',
syntax: '<color>',
inherits: true,
initialValue: '#7c3aed',
},
{
name: '--ds-spacing-brand-offset',
syntax: '<length>',
inherits: false,
initialValue: '0px',
},
];
export function registerRuntimeTokens() {
if (!window.CSS || !CSS.registerProperty) {
// Browser does not support Houdini — plain vars still work
return;
}
for (const token of RUNTIME_TOKENS) {
try {
CSS.registerProperty({
name: token.name,
syntax: token.syntax,
inherits: token.inherits,
initialValue: token.initialValue,
});
} catch (err) {
// InvalidModificationError thrown if already registered via @property in CSS
if (err.name !== 'InvalidModificationError') {
console.warn(`Token registration failed for ${token.name}:`, err);
}
}
}
}
Call registerRuntimeTokens() early in your app bootstrap, before any component renders that depends on these tokens.
Why this works: CSS.registerProperty is synchronous and takes effect before the next paint. The try/catch around InvalidModificationError handles the case where @property in your stylesheet already registered the same name — that error is expected and safe to ignore. Catching other errors surfaces genuine mistakes like malformed initialValue strings. For a cross-reference on how JSON Schema validation for tokens can catch initialValue type mismatches before they reach the browser, see the CI pipeline guides.
Verification
After building, open DevTools and verify registration is working:
-
Computed panel coercion check. In the Elements panel, select any element that uses a typed token. In the Computed tab, find the property and assign an invalid value inline:
element.style.setProperty('--ds-color-action-primary', '42px'). The computed value should revert to#7c3aed(yourinitial-value) — not42px. If it shows42px, the@propertyrule was not applied (check the Sources panel for parse errors intokens.css). -
CSS.registerPropertyreflection. In the Console, run:CSS.registerProperty({ name: '--ds-color-action-primary', syntax: '<color>', inherits: true, initialValue: '#7c3aed' });If the browser throws
InvalidModificationError, the property is already registered from your stylesheet — that is the expected success state. -
Transition smoke test. Apply a typed color token to a button and toggle its value via JavaScript. A smooth transition (rather than an instant jump) confirms the browser treats the property as a typed value eligible for interpolation.
-
Build output audit. Run
grep -c '^@property' dist/tokens.cssand confirm the count matches the number of tokens in your JSON. A mismatch signals the generation script skipped tokens with missing or invalidsyntaxfields.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
initial-value is required but missing |
You declared syntax as something other than "*" without providing initial-value |
Add a valid initial-value matching the declared syntax to every token; syntax: "*" is the only case where initial-value is optional |
| Token value does not inherit into nested elements | inherits: false set on a token that should cascade (e.g. a color token) |
Change inherits to true for tokens that represent inheritable style signals; audit INHERITS_BY_CATEGORY in your build script |
| Flash of unstyled token on page load (FOUC) | CSS.registerProperty called after first paint; @property in CSS processed after components render |
Move @property registration CSS above component stylesheets in import order; call registerRuntimeTokens() synchronously in <head> inline script for critical tokens |
InvalidModificationError thrown at runtime |
Same property registered by both CSS @property and CSS.registerProperty with conflicting descriptors |
Catch InvalidModificationError and treat it as a no-op; ensure the syntax and initialValue in both registrations are identical |
@property rules silently discarded |
@property nested inside a selector, @layer, or @media block |
@property must be a top-level rule — move all registrations outside any wrapper block in the generated CSS |
Migration Note from Plain --var Declarations
If your codebase already uses unregistered custom properties and you are adding @property on top, treat the migration as a two-phase operation:
Phase 1 — Add registrations without changing values. Run the generation script against your existing token JSON and prepend the @property rules to your existing CSS. Every --var declaration already in :root now has a registration backing it. No visual change occurs; you are only adding metadata.
Phase 2 — Tighten initial-value and remove fallback arguments. Once phase 1 is verified in production (one or two sprint cycles), remove redundant fallback values from var(--ds-color-action-primary, #7c3aed) calls — the registered initial-value now provides that safety net at the spec level. Update your Stylelint rules to flag var(--ds-*, <fallback>) patterns as a lint warning so new code stops adding them.
You do not need a codemod for phase 1. A shell one-liner confirms coverage before phase 2:
# Count tokens in JSON vs @property rules in generated CSS
TOKENS=$(node -e "const t = require('./tokens/tokens.json'); let n=0; JSON.stringify(t, (k,v) => { if (v && v.value) n++; return v; }); console.log(n);")
RULES=$(grep -c '^@property' src/tokens/tokens.css)
echo "Tokens: $TOKENS @property rules: $RULES"
[ "$TOKENS" -eq "$RULES" ] && echo "Coverage complete." || echo "Gap: check build script."
Related
- Houdini @property Type-Safe Tokens — parent reference covering the full type-safe token model
- Animating Design Tokens with Typed Custom Properties — sibling guide on using registered tokens for smooth CSS animations
- JSON Schema Validation for Design Tokens in CI — catch malformed
syntaxandinitialValueentries before they reach the browser