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.
@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
@propertydeclaration 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@propertyfor static design tokens; reserveCSS.registerProperty()for tokens whosesyntaxorinitial-valuemust be determined at runtime. -
Strict
syntaxdescriptors 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 declaredinheritsvalue. 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-valueas a required field: Theinitial-valuedescriptor is mandatory wheninherits: falseand effectively mandatory in practice wheninherits: true, because without it any element that does not have the property set in the cascade will compute toIACVT(invalid at computed-value time), which renders as the property’s unset value. Always supply a sensibleinitial-value— even if it istransparentfor color or0for length. -
Build-time generation vs. hand-authored
@propertyblocks: Hand-authoring is error-prone at scale: a token renamed in the source JSON but not in its@propertyrule silently loses its registration. Generate@propertyblocks from the same token source that generates the--variableassignments. 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.
-
Author token source in structured JSON. Each token carries a
typefield that maps to a CSS@propertysyntax 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 } } } } -
Map W3C token
$typevalues to CSSsyntaxstrings. In Style Dictionary, write a custom format or transform that converts the standardized type names to@property syntaxdescriptors.// 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'); }, }; -
Output two separate CSS artifacts. One file contains
@propertyregistrations; the other contains the--variable: valueassignments 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; } -
Reference tokens in component CSS with
transitionrules. 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:rootvalue 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. -
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.
Related
- Token Fundamentals & Naming Conventions — parent overview covering the full primitive→semantic→component model
- Registering design tokens with the @property rule — step-by-step guide to authoring and generating
@propertyblocks at scale - Animating design tokens with typed custom properties — transition and keyframe techniques that depend on type registration
- Runtime Theme Switching — how typed tokens enable smooth theme transitions without JavaScript