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.

Build pipeline: JSON tokens to @property rules A flowchart showing token JSON parsed by a build script to emit @property CSS declarations, then consumed by component CSS, with a progressive-enhancement fallback for unsupported browsers. tokens.json primitive + semantic Build Script parse · infer syntax set inherits / initial tokens.css @property rules + :root declarations Component CSS var(--ds-*) Fallback layer plain --var (no @property) Token JSON → @property Build Pipeline browsers without Houdini all modern browsers
The build script reads 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/promises API.
  • Your CSS entry point is bundled by a tool that supports @property passthrough (PostCSS, Vite, esbuild, or plain concatenation all work — do not need transforms).
  • Browsers you must support without degradation: any browser that does not understand @property must still receive correct visual output through the plain --var fallback. Verify your baseline using the @supports check 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:

  1. 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 (your initial-value) — not 42px. If it shows 42px, the @property rule was not applied (check the Sources panel for parse errors in tokens.css).

  2. CSS.registerProperty reflection. 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.

  3. 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.

  4. Build output audit. Run grep -c '^@property' dist/tokens.css and confirm the count matches the number of tokens in your JSON. A mismatch signals the generation script skipped tokens with missing or invalid syntax fields.

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."