Animating Design Tokens with Typed Custom Properties
Part of Houdini @property Type-Safe Tokens. This page walks through exactly how to make design token values animate and transition smoothly by registering them with a concrete type — covering color tokens, length-based gradient stops, and angle-driven conic gradients, each of which is inert to CSS interpolation until a type is declared.
<color> yields a smooth SRGB blend across the full duration.Prerequisites
Before working through the steps below, confirm the following are in place:
- Registered token file — a CSS source file (or build output) where primitive and semantic tokens are declared with
@propertyat-rule registration; unregistered variables cannot animate. - Browser support target —
@propertyis supported in Chromium 85+, Firefox 128+, and Safari 16.4+. If you must support earlier Safari, supply a@supportsfallback that degrades to an instant swap. - Token tier discipline — only semantic tokens (not primitive scale tokens) should be animated; animating
--ds-color-violet-500directly couples component motion to the raw palette. - No
var()insideinitial-value—initial-valuein@propertymust be a concrete value literal, not a reference to another custom property. prefers-reduced-motionaudit — every animation added in these steps must be gated or reduced under that media query before shipping.
Step-by-Step Implementation
Step 1: Understand why untyped tokens cannot transition
Before writing any @property registration, verify the failure mode so you understand what the fix actually addresses.
/* tokens/semantic.css — UNTYPED, no @property registration */
:root {
--ds-color-action-primary: #7c3aed;
}
/* component.css */
.button {
background-color: var(--ds-color-action-primary);
transition: --ds-color-action-primary 0.4s ease;
}
.button:hover {
--ds-color-action-primary: #5b21b6;
}
Open DevTools, hover the button, and watch the Animations panel: you will see no keyframe interpolation. The property flips at the 50% mark of the transition duration. The browser treats the value as an opaque string — it has no idea that #7c3aed and #5b21b6 are colors, so it cannot mix them.
Why this works (as a diagnostic): CSS custom properties without a registered syntax are of type <declaration-value> — a bag of tokens — which the interpolation algorithm classifies as “discrete.” Discrete properties jump; they never blend.
Step 2: Register the color token with a typed @property
Add the registration at the top of your token file, before any :root declarations that set the value.
/* tokens/semantic.css */
@property --ds-color-action-primary {
syntax: "<color>";
inherits: true;
initial-value: #7c3aed;
}
@property --ds-color-action-primary-hover {
syntax: "<color>";
inherits: true;
initial-value: #5b21b6;
}
:root {
--ds-color-action-primary: #7c3aed;
--ds-color-action-primary-hover: #5b21b6;
}
Why this works: Once syntax: "<color>" is in place, the browser’s interpolation engine knows to use color mixing in the sRGB space (or oklch if you use color-mix() as the initial value). The cascade still operates identically — @property does not change how specificity or inheritance resolves. It only tells the animation subsystem how to compute intermediate values.
Step 3: Wire the transition in the component
/* components/button.css */
.button {
background-color: var(--ds-color-action-primary);
/* Transition the token itself, not the property that consumes it */
transition: --ds-color-action-primary 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.button:hover {
--ds-color-action-primary: var(--ds-color-action-primary-hover);
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
}
Why this works: Transitioning --ds-color-action-primary rather than background-color means every property consuming that token (background, border, box-shadow highlight, etc.) all interpolate simultaneously from a single declaration. The reduced-motion block is not optional — design tokens are often consumed by many components, and a motion preference applied at the token level cascades everywhere at once.
Step 4: Animate a <length> gradient stop
The same principle applies to gradient positions. An unregistered --ds-gradient-stop-mid used inside a background: linear-gradient(…) will not animate because the browser sees the entire gradient string as opaque text.
@property --ds-gradient-stop-mid {
syntax: "<length-percentage>";
inherits: false;
initial-value: 40%;
}
.progress-bar {
--ds-gradient-stop-mid: 40%;
background: linear-gradient(
to right,
var(--ds-color-action-primary) var(--ds-gradient-stop-mid),
#e2e8f0 var(--ds-gradient-stop-mid)
);
transition: --ds-gradient-stop-mid 0.5s ease-in-out;
}
.progress-bar[aria-valuenow="75"] {
--ds-gradient-stop-mid: 75%;
}
@media (prefers-reduced-motion: reduce) {
.progress-bar {
transition: none;
}
}
Why this works: Registering as <length-percentage> tells the interpolation engine to treat the value as a numeric percentage. The gradient itself is never transitioned — the browser repaints the gradient at each interpolated stop position, producing a smooth fill animation with zero JavaScript.
Step 5: Rotate an <angle> conic gradient
Conic gradients are a natural fit for loaders and pie-chart-style indicators. Without a registered <angle> token, setting the rotation via a custom property produces a step change.
@property --ds-loader-rotation {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes spin-token {
to {
--ds-loader-rotation: 360deg;
}
}
.loader {
--ds-loader-rotation: 0deg;
width: 48px;
height: 48px;
border-radius: 50%;
background: conic-gradient(
from var(--ds-loader-rotation),
var(--ds-color-action-primary) 0%,
transparent 70%
);
animation: spin-token 1s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.loader {
animation: none;
/* Render static at 25% fill to indicate loading state without motion */
--ds-loader-rotation: 0deg;
background: conic-gradient(
from 0deg,
var(--ds-color-action-primary) 0%,
transparent 25%
);
}
}
Why this works: <angle> interpolation is well-defined — the browser interpolates from 0deg to 360deg numerically. Without @property, the conic-gradient() string is rebuilt on each frame only if the property is registered; otherwise the browser cannot inject intermediate values and the property either snaps or does nothing. The reduced-motion block replaces the spinner with a static arc, which still communicates “loading” without triggering vestibular discomfort.
Step 6: Stack multiple typed token transitions without conflicts
A common production mistake is registering the same token multiple times with different syntax values in different stylesheets. The last @property to load wins for animation purposes, but the actual cascade for the property value is unaffected — which leads to confusing behavior where the token resolves correctly but fails to interpolate.
/* tokens/motion.css — load AFTER tokens/semantic.css */
@property --ds-color-action-primary {
syntax: "<color>";
inherits: true;
initial-value: #7c3aed;
}
@property --ds-surface-overlay-opacity {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
/* All three tokens in a single transition shorthand */
.modal-backdrop {
--ds-surface-overlay-opacity: 0;
background-color: var(--ds-color-action-primary);
opacity: var(--ds-surface-overlay-opacity);
transition:
--ds-surface-overlay-opacity 0.2s ease,
--ds-color-action-primary 0.35s ease;
}
.modal-backdrop.is-open {
--ds-surface-overlay-opacity: 0.6;
--ds-color-action-primary: #312e81;
}
@media (prefers-reduced-motion: reduce) {
.modal-backdrop {
transition: none;
}
}
Why this works: Listing multiple custom property transitions in transition is identical in behavior to listing any other properties. The browser runs each interpolation independently. Keeping registrations in a dedicated tokens/motion.css file (loaded after your base token file) prevents accidental re-registration conflicts and makes it easy to audit which tokens participate in animation.
Step 7: Gate new registrations behind @supports
Until your browser support matrix fully covers @property, wrap registrations and their dependent transitions in a feature query so unregistered fallback behavior is explicit and intentional rather than a silent bug.
/* tokens/typed-tokens.css */
/* Unregistered baseline — instant swap, always works */
:root {
--ds-color-action-primary: #7c3aed;
}
/* Registered + animated — only where @property is supported */
@supports (syntax: "<color>") {
@property --ds-color-action-primary {
syntax: "<color>";
inherits: true;
initial-value: #7c3aed;
}
.button {
transition: --ds-color-action-primary 0.35s ease;
}
}
Why this works: @supports (syntax: "<color>") is itself a Houdini feature query. Browsers that do not understand @property evaluate the @supports block as false and skip it entirely, falling back to the discrete swap. Browsers that do support @property get the smooth transition. This lets you ship progressive enhancement without shipping broken experiences to older clients.
Verification
After implementing typed transitions, confirm correct behavior in three places:
DevTools Animations panel (Chrome/Edge): Trigger the transition, then open DevTools > Animations. You should see the typed custom property appear as an animated property with a curved easing timeline, not a flat step. If the property appears with a discrete step icon, @property is not being applied — check for a syntax error in the @property block, or a load-order issue where the registration appears after the first use of the property.
DevTools Computed styles during transition: While the animation is mid-flight, inspect the element’s computed styles. The typed custom property should show a blended intermediate value (e.g., rgb(148, 54, 180) between the violet start and a purple end). An unregistered property will show either the start or end value, never an intermediate.
getComputedStyle assertion in a test:
// playwright test
const color = await page.evaluate(() => {
const el = document.querySelector(".button");
// trigger hover programmatically
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
// read mid-transition (10ms in, transition is 350ms)
return new Promise((resolve) => {
setTimeout(() => {
resolve(getComputedStyle(el).getPropertyValue("--ds-color-action-primary").trim());
}, 10);
});
});
// An intermediate color value should not equal the start or end exactly
expect(color).not.toBe("#7c3aed");
expect(color).not.toBe("#5b21b6");
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
Token transitions as a hard step despite @property registration |
@property block appears after the first :root assignment that sets the value, or is inside a scope that does not match the element |
Move @property declarations to the top of the token file, before any :root blocks; @property registrations are not order-sensitive for cascade values but must be parsed before the animation begins |
initial-value is invalid and registration is silently ignored |
initial-value contains a var() reference or a value that does not match the declared syntax |
Use a concrete, literal value for initial-value; if you need a different default per theme, override the property’s value via :root after registration |
| Color interpolation produces unexpected hues mid-transition | Browser interpolates in sRGB; complex colors (oklch-defined tokens) may look different than expected at the midpoint | Accept sRGB blending as the default, or define a keyframe animation using color-mix(in oklch, …) for perceptually uniform mid-values |
@supports (syntax: "<color>") block is never entered |
Browser does not support @property at all, or the feature query syntax is incorrect |
Confirm browser version; the feature query must use the exact string syntax: "<color>" with a valid syntax descriptor — check for typos or stray quotes |
| Transition works in Chrome but not in Firefox 127 or earlier | @property landed in Firefox 128 |
Add the @supports gate from Step 7; consider a JS-based fallback only if the animation is load-bearing for the UX |
Migration Note
If your token system currently uses CSS custom properties without registration, you can migrate incrementally. Start by identifying the five to ten tokens that are actively transitioned or animated in the codebase — search for transition: declarations that reference custom properties. Register only those tokens first. The rest of your token file can remain unregistered indefinitely; @property registration is additive and does not change how an unregistered token resolves or inherits. Once the high-motion tokens are confirmed stable in CI visual regression, expand registration to cover the full semantic tier. For zero-JS runtime theme switching scenarios, typed tokens are particularly valuable because theme transitions fire via class or attribute changes with no JavaScript orchestration — the CSS engine handles interpolation entirely, making typed registrations the correct place to encode all animation intent.
Related
- Houdini @property Type-Safe Tokens — parent section covering the full scope of typed token registration
- Registering Design Tokens with the @property Rule — sibling page on declaring
syntax,inherits, andinitial-valuecorrectly - Zero-JS Runtime Theme Switching with CSS Variables — cross-topic page where typed tokens eliminate JavaScript from theme transition orchestration