Houdini @property: Type-Safe Design Tokens

Part of Token Fundamentals & Naming Conventions. This page addresses the specific sub-problem of giving design tokens a declared type — so the browser can validate values, interpolate them during animation, and inherit them correctly — using the CSS Properties and Values API Level 1, commonly called Houdini @property.

Without type declarations, every CSS custom property is an untyped string. The browser performs no validation: --ds-color-action-primary: purple-ish is as legal as a valid hex. More critically, the browser cannot interpolate between two untyped values during a CSS transition or animation, so you lose smooth color fades and numeric easing entirely. Registering a token with an explicit syntax descriptor solves both problems simultaneously.


Untyped vs. typed custom property comparison Two columns comparing an untyped CSS custom property on the left with a typed @property registration on the right, showing validation and interpolation differences. Untyped Custom Property @property Registration --color: purple-ish; /* no syntax check */ Validation: NONE Invalid values silently accepted Animation: DISCRETE No interpolation — instant flip Inherits: per-element cascade only @property --color { syntax: "<color>"; inherits: true; initial-value: #000; } Validation: ENFORCED Invalid values fall back to initial Animation: SMOOTH Per-channel color interpolation Inherits: declared, predictable register
Left: an untyped custom property accepts any string, cannot interpolate, and provides no validation. Right: a registered @property enforces a type, enables smooth animation, and makes inheritance explicit.

Problem Framing

Consider a button component that transitions its background color on hover. If --ds-color-action-primary is an ordinary custom property, the browser treats the value as an opaque string and cannot compute a mid-point between two colors. The transition fires, but the property jumps instantaneously at the 50% mark — the transition-duration is effectively ignored. The same failure happens with numeric tokens: --ds-space-inline-md transitioning from 1rem to 1.5rem produces no animated movement.

The failure is silent. Nothing throws an error; the UI just looks broken to users on slower connections or reduced-motion overrides where transition timing matters. In a large design system serving dozens of teams, this class of defect compounds quickly: engineers work around it with explicit element-level properties (background-color: var(--ds-color-action-primary) wrapped in a transition: background-color), which breaks the abstraction layer and reintroduces duplication.

Typed registration via @property fixes the root cause, not the symptom.


Three-Tier Architectural Trade-offs

The decision to register tokens with @property touches all three tiers of the primitive→semantic→component model.

  • Primitive tokens registered, semantics unregistered vs. all tiers registered: Registering only primitives is simpler and reduces generated CSS size, but semantic tokens that alias primitives do not inherit the type — they remain strings unless also registered. Full registration of semantic tokens adds bytes but yields correct animation and validation at the layer components actually consume.

  • CSS @property declaration vs. CSS.registerProperty() in JavaScript: The CSS form is statically analyzable, tree-shakeable by PostCSS, and requires no JS parse before first paint. The JS form supports dynamic registration (useful for runtime theme injection) but runs after the CSSOM is constructed, creating a window where the property is briefly untyped. Prefer CSS @property for static design tokens; reserve CSS.registerProperty() for tokens whose syntax or initial-value must be determined at runtime.

  • Strict syntax descriptors vs. syntax: "*": "*" registers the property for inheritance tracking but confers no validation or interpolation benefit — it is functionally equivalent to a custom property with a declared inherits value. Use concrete syntax strings (<color>, <length>, <number>) for any token you want to animate or validate. Reserve "*" only for structured composite tokens whose value cannot be expressed as a single CSS type.

  • initial-value as a required field: The initial-value descriptor is mandatory when inherits: false and effectively mandatory in practice when inherits: true, because without it any element that does not have the property set in the cascade will compute to IACVT (invalid at computed-value time), which renders as the property’s unset value. Always supply a sensible initial-value — even if it is transparent for color or 0 for length.

  • Build-time generation vs. hand-authored @property blocks: Hand-authoring is error-prone at scale: a token renamed in the source JSON but not in its @property rule silently loses its registration. Generate @property blocks from the same token source that generates the --variable assignments. This ensures the registration and the value always stay in sync.


Build Pipeline: Define → Register → Consume

The following numbered pipeline assumes Style Dictionary as the token compiler, but the concepts apply to Theo, Cobalt, or a custom build script.

  1. Author token source in structured JSON. Each token carries a type field that maps to a CSS @property syntax string. This is the single source of truth for both the variable value and its type registration.

    {
      "color": {
        "action": {
          "primary": {
            "$value": "#7c3aed",
            "$type": "color",
            "inherits": true
          }
        }
      },
      "space": {
        "inline": {
          "md": {
            "$value": "1rem",
            "$type": "dimension",
            "inherits": false
          }
        }
      }
    }
  2. Map W3C token $type values to CSS syntax strings. In Style Dictionary, write a custom format or transform that converts the standardized type names to @property syntax descriptors.

    // style-dictionary/formats/at-property.js
    const TYPE_TO_SYNTAX = {
      color:      '<color>',
      dimension:  '<length>',
      number:     '<number>',
      percentage: '<percentage>',
      duration:   '<time>',
      angle:      '<angle>',
    };
    
    module.exports = {
      name: 'css/at-property',
      formatter({ dictionary }) {
        return dictionary.allTokens
          .filter(token => token.$type && TYPE_TO_SYNTAX[token.$type])
          .map(token => {
            const name    = `--${token.name}`;
            const syntax  = TYPE_TO_SYNTAX[token.$type];
            const inherits = token.inherits !== false ? 'true' : 'false';
            const initial  = token.value;
            return [
              `@property ${name} {`,
              `  syntax: "${syntax}";`,
              `  inherits: ${inherits};`,
              `  initial-value: ${initial};`,
              `}`,
            ].join('\n');
          })
          .join('\n\n');
      },
    };
  3. Output two separate CSS artifacts. One file contains @property registrations; the other contains the --variable: value assignments in :root. Load the registrations file first — registration must occur before any element computes a value for the property.

    /* tokens/registered.css — generated, load first */
    @property --ds-color-action-primary {
      syntax: "<color>";
      inherits: true;
      initial-value: #7c3aed;
    }
    
    @property --ds-space-inline-md {
      syntax: "<length>";
      inherits: false;
      initial-value: 1rem;
    }
    /* tokens/values.css — generated, load after registered.css */
    :root {
      --ds-color-action-primary: #7c3aed;
      --ds-space-inline-md: 1rem;
    }
  4. Reference tokens in component CSS with transition rules. Because the token is typed, the browser knows how to interpolate <color> values and the transition works without any element-level property duplication.

    /* components/button.css */
    /* @depends: --ds-color-action-primary (color), --ds-space-inline-md (length) */
    .btn-primary {
      background-color: var(--ds-color-action-primary);
      padding-inline: var(--ds-space-inline-md);
      transition:
        background-color 150ms ease-out,
        padding-inline   150ms ease-out;
    }
    
    .btn-primary:hover {
      --ds-color-action-primary: #6d28d9;
    }

    Why this works: overriding a typed property on a scoped selector (hover) changes the computed value for that element, and because the type is <color>, the browser interpolates smoothly between the :root value and the hover override. This technique powers runtime theme switching — swap the property value on a scope selector and every typed token that reads from it transitions without JS.

  5. Validate in CI that every semantic token has a registration. See the CI snippet in the next section.


Validation & Quality Gates

CI Snippet: Verify Every Semantic Token Has a Registered Type

# .github/workflows/token-type-audit.yml
name: Token Type Safety Audit
on:
  pull_request:
    paths:
      - 'tokens/**'
      - 'styles/tokens/**'

jobs:
  type-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - run: npm ci

      - name: Build token artifacts
        run: npm run build:tokens

      - name: Assert every semantic token has @property registration
        run: |
          node scripts/assert-registrations.js \
            --values  styles/tokens/values.css \
            --registered styles/tokens/registered.css \
            --tier semantic
// scripts/assert-registrations.js
import { readFileSync } from 'node:fs';
import { parseArgs } from 'node:util';

const { values: args } = parseArgs({
  options: {
    values:     { type: 'string' },
    registered: { type: 'string' },
    tier:       { type: 'string', default: 'semantic' },
  },
});

const valuesCss     = readFileSync(args.values, 'utf8');
const registeredCss = readFileSync(args.registered, 'utf8');

// Extract all --ds-<tier>-* property names from values.css
const valueRe = /--ds-([\w-]+)/g;
const declared = new Set([...valuesCss.matchAll(valueRe)].map(m => `--ds-${m[1]}`));

// Extract all @property names from registered.css
const propRe = /@property\s+(--[\w-]+)/g;
const registered = new Set([...registeredCss.matchAll(propRe)].map(m => m[1]));

// Filter to the requested tier
const tierPrefix = `--ds-${args.tier === 'semantic' ? '' : args.tier + '-'}`;
const unregistered = [...declared]
  .filter(name => name.startsWith('--ds-color-') || name.startsWith('--ds-space-') || name.startsWith('--ds-font-'))
  .filter(name => !registered.has(name));

if (unregistered.length > 0) {
  console.error('Missing @property registrations for:');
  unregistered.forEach(n => console.error(`  ${n}`));
  process.exit(1);
}

console.log(`All ${registered.size} semantic tokens have @property registrations.`);

Tool Table

Tool Purpose Integration Point
Style Dictionary custom format Generate @property blocks from token JSON Build step, pre-bundle
Node assert-registrations.js Fail CI when a semantic token lacks a registration GitHub Actions, PR gate
Stylelint custom-property-pattern Enforce naming convention on registered properties Pre-commit hook, CI lint
Chromatic / Percy Confirm typed transition animations render correctly Visual regression, PR gate
@csstools/postcss-@property Polyfill @property for browsers that lack support PostCSS build pipeline

Cross-Cluster Dependency Mapping

Parent Section Sibling Topic Integration Point Validation Strategy
Token Fundamentals & Naming Conventions Color Palette Architecture <color> syntax for all color tokens; oklch values require initial-value to be a valid <color> Assert registered syntax matches token $type in CI
Token Fundamentals & Naming Conventions Spacing & Layout Tokens <length> syntax for all dimension tokens; clamp() values are valid <length> for typed properties Confirm initial-value parses as <length>; test with CSS.supports()
Token Fundamentals & Naming Conventions Typography Scale Systems <length> for font-size tokens; <number> for line-height unitless values Assert unitless line-height tokens use <number> not <length>
Advanced Theming & Dark Mode Runtime Theme Switching Typed tokens enable smooth transitions when the active theme class changes; use @layer to manage registration priority Confirm theme-swap animation in Chromatic across both themes
/* @depends: --ds-color-action-primary (<color>), --ds-space-inline-md (<length>) */
/* registration must be loaded before :root values */
@import url("tokens/registered.css");
@import url("tokens/values.css");

Production Code Reference

Registering a Full Semantic Color Scale

/* tokens/registered.css — excerpt for color-action tier */
@property --ds-color-action-primary {
  syntax: "<color>";
  inherits: true;
  initial-value: #7c3aed;
}

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

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

@property --ds-color-surface-default {
  syntax: "<color>";
  inherits: true;
  initial-value: #ffffff;
}

Why this works: Every token in the action color tier is independently animatable. A dark-mode implementation can override --ds-color-surface-default on [data-theme="dark"] and all components that consume it will transition smoothly if a transition: background-color rule is in place — no JS required.

Registering Numeric and Length Tokens

@property --ds-border-radius-md {
  syntax: "<length>";
  inherits: false;
  initial-value: 6px;
}

@property --ds-opacity-disabled {
  syntax: "<number>";
  inherits: true;
  initial-value: 0.38;
}

@property --ds-rotate-loading {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
/* Typed angle enables CSS @keyframes interpolation */
@keyframes spin {
  to { --ds-rotate-loading: 360deg; }
}

.icon-loading {
  animation: spin 800ms linear infinite;
  transform: rotate(var(--ds-rotate-loading));
}

Why this works: Because --ds-rotate-loading is typed as <angle>, the @keyframes rule can interpolate from 0deg to 360deg. Without the @property registration, the browser treats the property as a string and the @keyframes block has no effect on it — only properties the browser understands natively can be animated in keyframes without Houdini.

For step-by-step coverage of the registration workflow, see Registering design tokens with the @property rule. For animation-specific techniques including @keyframes and transition coordination, see Animating design tokens with typed custom properties.


Browser Support and Fallbacks

@property has broad support as of 2025: Chromium 85+, Firefox 128+, Safari 16.4+. The gap that remains is older mobile browsers and some enterprise Chromium deployments pinned to ESR builds.

The recommended fallback strategy is layered:

/* 1. Register the property (browsers that support @property use this) */
@property --ds-color-action-primary {
  syntax: "<color>";
  inherits: true;
  initial-value: #7c3aed;
}

/* 2. Assign the value unconditionally — works in all browsers */
:root {
  --ds-color-action-primary: #7c3aed;
}

/* 3. Provide a concrete fallback for transitions in unsupported browsers */
.btn-primary {
  background-color: #7c3aed; /* fallback for browsers without @property */
  background-color: var(--ds-color-action-primary);
  transition: background-color 150ms ease-out;
}

In browsers that do not support @property, the registration block is ignored, the variable assignment still works, and the component renders correctly — it just does not animate. That is a progressive enhancement, not a breaking regression. Teams that require smooth transitions in older browsers can use @csstools/postcss-@property to emit a CSS.registerProperty() call as a polyfill script.

For PostCSS configuration:

// postcss.config.js
import atProperty from '@csstools/postcss-@property';

export default {
  plugins: [
    atProperty({
      // Emit a <script> polyfill for browsers without native @property
      preserve: true,
    }),
  ],
};

The preserve: true option retains the CSS @property block while also emitting the JS polyfill, so browsers with native support use the CSS path and the polyfill handles the rest.


Diagnostic Matrix

Diagnostic Step Execution Detail
Confirm the property is registered In DevTools console: CSS.supports("(--ds-color-action-primary: red)") should return true after registration; check also in the Computed tab — registered properties show a type badge in Chrome 114+
Verify load order Inspect the network waterfall: registered.css must complete before any element computes --ds-color-action-primary. If values.css loads first, the token computes as the initial-value at parse time, then updates — causing a flash
Confirm initial-value is a valid CSS value for the declared syntax @property --ds-color-action-primary { syntax: "<color>"; initial-value: purple-ish; } is invalid — the block is silently ignored. Test with the DevTools Sources panel; invalid @property blocks are not listed in the Styles inspector
Check that semantic tokens are registered, not just primitives Run assert-registrations.js locally against the compiled CSS; if semantic tokens alias unregistered primitives, they remain typed only at the primitive level
Diagnose a missing transition Toggle @supports (background-color: oklch(0 0 0)) style blocks to isolate capability; confirm the animated property’s type is <color> not "*" in the registration

Root Causes and Resolutions

Symptom Likely Root Cause Resolution
Transition fires but jumps instantly Token is used in transition but is not registered or is registered with syntax: "*" Add a typed @property block for the token; confirm load order
Invalid value renders as initial-value unexpectedly A downstream token assignment uses a value that does not match the declared syntax (e.g., a <length> token set to auto) Either change the syntax to "*" for tokens that accept keyword values, or split into separate typed and keyword tokens
@property block appears in CSS but has no effect Malformed initial-value for the declared syntax; the browser silently discards the entire block Open DevTools Elements → Styles; the property will not appear as typed. Fix the initial-value to be a valid instance of the syntax type
Polyfill script causes a flash of unregistered state CSS.registerProperty() runs after first paint Move the polyfill <script> inline before the first <link rel="stylesheet">, or emit it in the <head> at build time
CI audit passes but animation still fails in production Build outputs registered.css and values.css but the application bundles them in wrong order Fix import order in the entry CSS; add an integration test that loads the page and checks window.CSS.supports() after the bundle executes

Frequently Asked Questions

Does @property work inside Shadow DOM?

Yes, with a caveat. Registrations made in the light-DOM stylesheet are global — they apply to custom properties regardless of where the property is consumed, including inside shadow roots. If a web component defines its own @property registrations in an adopted stylesheet, those registrations are also global (the Properties and Values API does not scope registrations to shadow trees). This means a @property name collision between two components or between a component and the host page will produce unpredictable results. Namespace your token names rigorously: --ds- for design system tokens, --cmp-button- for component-local properties.

Can @property register calc() expressions as initial-value?

No. The initial-value must be a concrete, resolved CSS value — not a function call, variable reference, or computed expression. initial-value: calc(1rem + 4px) is invalid. If your semantic token’s value is derived from a primitive via calc(), the initial-value in the @property block must be the pre-computed numeric result (e.g., 20px). Use your build step to compute the value and emit the resolved number.

Should component-local properties be registered too?

Only if they need to animate or if you want inheritance control. Component-local custom properties (e.g., --_btn-bg) that are never transitioned and always set explicitly on the element do not benefit from @property. Registering them adds bytes to the stylesheet for no functional gain. Apply the rule selectively: register tokens that cross component boundaries or participate in transitions; leave internal implementation tokens unregistered.