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 Link to this section
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 Link to this section
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 Link to this section
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 Link to this section
CI Snippet: Verify Every Semantic Token Has a Registered Type Link to this section
# .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 Link to this section
| 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-Domain Dependency Mapping Link to this section
| 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 Link to this section
Registering a Full Semantic Color Scale Link to this section
/* 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 Link to this section
@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 Link to this section
@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 Link to this section
| 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 Link to this section
| 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 |
Where Registration Pays for Itself Link to this section
Registering every token is unnecessary and adds noise to the token file. The cases where it clearly earns its place are narrow and easy to recognise.
Register a property when it is animated or transitioned — this is the primary reason the feature
exists, and untyped properties simply do not interpolate. Register it when an invalid value would
be dangerous rather than merely wrong: a typed property falls back to its declared initial value,
while an untyped one inherits the invalid string down the tree, which is how a single typo turns into
a page of unstyled components. And register it when the property must not inherit — a
component-local variable that leaks into descendants causes bugs that are painful to trace, and
inherits: false is the only way to prevent it.
Everything else — the bulk of a semantic token set, which is set once per theme and read directly — gains nothing from registration except a longer token file and a browser-support consideration that otherwise would not exist.
Frequently Asked Questions Link to this section
Does @property work inside Shadow DOM? Link to this section
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? Link to this section
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? Link to this section
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 Link to this section
- 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