The Complete Guide to CSS @property — From Type-Safe Design Token Declarations to CSS Variable Animation (`css @property animation`, `design tokens type safety`) Implementation
Anyone who has worked on a design system has probably encountered this situation at least once. You accidentally type #3b82f6px instead of #3b82f6 for --color-primary, and the browser silently moves on without a word. No error, no warning — the screen just looks slightly off. I used to spend minutes tracking down the cause, but it turns out the root problem is that existing CSS variables are nothing more than simple text substitution. From the browser's perspective, it has absolutely no idea whether the value is a color, a number, or a meaningless string.
@property is exactly what solves this fundamental limitation. Part of the CSS Houdini API (a collection of low-level APIs that extend the browser's CSS rendering engine with JavaScript), this feature lets you explicitly declare the type, initial value, and inheritance behavior of custom properties. By leveraging @property, you can give CSS variables type safety, create animations like gradient color transitions that were previously impossible with standard CSS, and build a design token pipeline from Figma to CSS that preserves type information end-to-end. It currently works in Chrome, Firefox, Safari, and Edge, covering approximately 93% or more of global browser usage.
Core Concepts
The Fundamental Limitations of CSS Variables and the Emergence of @property
Let's start by looking at how existing CSS custom properties work.
:root {
--color-primary: #3b82f6;
}
.button {
/* From the browser's perspective, this is equivalent to pasting the string "#3b82f6" */
background-color: var(--color-primary);
}The browser doesn't know whether the value of --color-primary is a color or a length. It's just a string. This means interpolation — smoothly transitioning between two values — is impossible, and invalid values are silently ignored.
@property addresses this problem head-on.
@property --color-primary {
syntax: "<color>";
inherits: true;
initial-value: hsl(217 91% 60%);
}With just three additional lines, the browser recognizes this variable as a color. Transitions now work, and if an invalid value is provided, it falls back to initial-value.
Three Required Descriptors
All three descriptors are required in a @property declaration. If any one is missing, the entire declaration is treated as invalid.
| Descriptor | Role | Example |
|---|---|---|
syntax |
Specifies the type of value | "<color>", "<length>", "<number>", "<angle>", "*" |
inherits |
Whether to inherit the value from a parent | true / false |
initial-value |
Fallback value on type mismatch | A valid absolute value matching the given type |
"*" is also in the syntax list, meaning "accept any value." Use it when you want to forgo type interpolation but still benefit from initial-value fallback and inherits isolation — or when the value is a dynamic combination like calc(var(--a) + var(--b)) that's difficult to pin to a specific type.
@property --opacity-overlay {
syntax: "<number>";
inherits: false; /* Not propagated to child elements */
initial-value: 0;
}The JavaScript Equivalent
The same registration can be done in JavaScript instead of using a CSS at-rule.
CSS.registerProperty({
name: '--color-primary',
syntax: '<color>',
inherits: true,
initialValue: 'hsl(217 91% 60%)',
});The two approaches are functionally equivalent, but they differ in one key way. The @property at-rule is registered at stylesheet parse time, so it's ready before JavaScript executes. I've used CSS.registerProperty() when wiring up theme-switching features — if you need to register with a different initial value at runtime based on user settings, this approach is far more flexible. The recommendation is to use the JavaScript approach for A/B testing or dynamic theme registration, and the at-rule for static token declarations.
Combining with @layer tokens — Explicit Priority Management
Separating design tokens into their own layer makes priority management much clearer. @property declarations can be placed inside @layer as-is.
There is one counterintuitive aspect. In CSS Cascade Layers, layers declared earlier have lower priority. In other words, with the declaration order @layer tokens, base, components, utilities, tokens has the lowest priority and utilities has the highest. This is a perfect structure for design tokens — tokens provide default values, and components or utilities can freely override them from above.
@layer tokens, base, components, utilities;
@layer tokens {
@property --color-primary {
syntax: "<color>";
inherits: true;
initial-value: hsl(217 91% 60%);
}
@property --spacing-base {
syntax: "<length>";
inherits: true;
initial-value: 4px;
}
@property --radius-md {
syntax: "<length>";
inherits: true;
initial-value: 8px;
}
}Practical Application
Example 1: Type-Safe Design Token Declarations
This is the pattern you can apply in production first. It's a matter of migrating the CSS variables you've been stacking in :root into a token layer alongside @property.
@layer tokens {
/* Color tokens */
@property --color-primary {
syntax: "<color>";
inherits: true;
initial-value: hsl(217 91% 60%);
}
@property --color-surface {
syntax: "<color>";
inherits: true;
initial-value: hsl(0 0% 100%);
}
/* Spacing tokens */
@property --spacing-base {
syntax: "<length>";
inherits: true;
initial-value: 4px;
}
/* Border radius tokens */
@property --radius-md {
syntax: "<length>";
inherits: true;
initial-value: 8px;
}
/* Overlay opacity — component-local, inheritance blocked */
@property --opacity-overlay {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
}
@layer components {
.button {
background-color: var(--color-primary);
border-radius: var(--radius-md);
padding: var(--spacing-base) calc(var(--spacing-base) * 4);
color: var(--color-surface);
}
}The key here is --opacity-overlay, declared with inherits: false. Because this token doesn't propagate to child elements, it's isolated to work only within components like modals or drawers. If you've ever struggled with unintended value propagation in a design system, you'll know just how welcome this feature is.
| Point | Description |
|---|---|
initial-value fallback |
If a non-color value like "red-ish" is set for --color-primary, it automatically falls back to hsl(217 91% 60%) |
| Inheritance isolation | inherits: false prevents value propagation within a component's scope |
| Type documentation | The stylesheet itself carries type information, providing the foundation for future tooling autocomplete |
Example 2: Gradient Color Transition Animation
Honestly, this is the most impressive use case for @property. It was previously impossible to transition gradient colors themselves using regular CSS variables. The first time I saw this code actually work, I thought "why did I only just find out about this."
@layer tokens {
@property --gradient-start {
syntax: "<color>";
inherits: false;
initial-value: #3b82f6;
}
@property --gradient-end {
syntax: "<color>";
inherits: false;
initial-value: #8b5cf6;
}
}
@layer components {
.card {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
transition:
--gradient-start 0.4s ease,
--gradient-end 0.4s ease;
}
.card:hover {
--gradient-start: #ef4444;
--gradient-end: #f97316;
}
}Because the <color> type is registered, the browser calculates intermediate colors between blue and red on every frame, producing a smooth gradient transition. With an unregistered CSS variable, even applying a transition would result in an abrupt change. Without type information, the browser has no idea what to interpolate.
Example 3: conic-gradient-Based Progress Arc — The True Value of the <angle> Type
This is the pattern where the <angle> type shines most clearly. Combined with conic-gradient, you can control the angle from JavaScript while getting smooth animations from CSS transitions. Here, initial-value: 0deg serves as the implicit starting point of the animation.
@layer tokens {
@property --arc-end {
syntax: "<angle>";
inherits: false;
initial-value: 0deg; /* Starts from this value when the @keyframes `from` is omitted */
}
}
@layer components {
.progress-ring {
width: 80px;
height: 80px;
border-radius: 50%;
background: conic-gradient(
#3b82f6 var(--arc-end),
#e5e7eb 0
);
transition: --arc-end 0.5s ease-out;
}
}function setProgress(element, percent) {
const angle = percent * 3.6; // 100% → 360deg
element.style.setProperty('--arc-end', `${angle}deg`);
}
setProgress(document.querySelector('.progress-ring'), 75); // Smoothly moves to 270degThe stop point of conic-gradient is specified via a CSS variable, and because that variable is of type <angle>, the CSS transition kicks in naturally every time JavaScript changes the value. This pattern cannot be implemented by animating transform: rotate() directly.
Example 4: Number-Based Progress Indicator
@layer tokens {
@property --progress {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
}
@layer components {
.progress-bar {
position: relative;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
transition: --progress 0.6s ease-out;
}
.progress-bar::after {
content: "";
position: absolute;
inset: 0;
background: #3b82f6;
width: calc(var(--progress) * 1%);
border-radius: inherit;
}
}function setProgress(element, value) {
element.style.setProperty('--progress', value);
}
setProgress(document.querySelector('.progress-bar'), 75);Because it's registered as a <number> type, transition: --progress 0.6s ease-out works correctly. You can create a progress bar that smoothly rises from 0 to 75 with just a few lines of CSS and JavaScript.
Example 5: Style Dictionary v4 + @property Automated Conversion Pipeline
As your design system grows in scale, manually declaring @property becomes unwieldy. Let's look at a pipeline that automatically converts JSON exported from Figma or Tokens Studio (a Figma plugin for managing design tokens as JSON) into CSS @property declarations. Style Dictionary is a build tool that automates this kind of token transformation.
// tokens/color.json — W3C DTCG (W3C Design Tokens Community Group standard format)
{
"color": {
"primary": {
"$value": "#3b82f6",
"$type": "color"
},
"surface": {
"$value": "#ffffff",
"$type": "color"
}
},
"spacing": {
"base": {
"$value": "4px",
"$type": "dimension"
}
}
}// style-dictionary.config.js
import StyleDictionary from 'style-dictionary';
const typeMap = {
color: '<color>',
dimension: '<length>',
number: '<number>',
fontWeight: '<number>',
};
StyleDictionary.registerFormat({
name: 'css/at-property',
format: ({ dictionary }) => {
const declarations = dictionary.allTokens.map(token => {
const syntax = typeMap[token.$type] ?? '*';
// Style Dictionary's default name transform flattens nested structures:
// color.primary → color-primary
return `@property --${token.name} {
syntax: "${syntax}";
inherits: true;
initial-value: ${token.value};
}`;
});
return `@layer tokens {\n${declarations.join('\n\n')}\n}`;
},
});
const sd = new StyleDictionary({
source: ['tokens/**/*.json'],
platforms: {
css: {
transformGroup: 'css',
files: [
{
destination: 'dist/tokens.css',
format: 'css/at-property',
},
],
},
},
});
await sd.buildAllPlatforms();The $type field from the W3C DTCG spec maps semantically and precisely to the syntax of CSS @property. For token.name, Style Dictionary's default name transform automatically converts the JSON nested structure (color.primary) into a CSS variable name format (color-primary). Once this pipeline is set up, the moment you modify a token in Figma, it flows automatically through to CSS with type information fully preserved.
Pros and Cons Analysis
Advantages
| Item | Description |
|---|---|
| Type safety | Values of the wrong type fall back to initial-value. Silent failures disappear |
| Animation/transition | Properties with declared types can be interpolated in CSS transitions and animations. Enables gradient color transitions and more |
| Inheritance control | inherits: false blocks child propagation of component-local properties. Strengthens style isolation |
| No JS dependency | The @property at-rule is registered at stylesheet parse time, ready before JavaScript executes |
| Type documentation | The stylesheet itself carries type information, providing the foundation for future tooling autocomplete and type hints |
| Pipeline standardization | The mapping between W3C DTCG $type and CSS syntax enables a type-preserving pipeline from design tools to CSS |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
initial-value constraint |
Cannot use dynamic values such as references to other variables, calc(), or clamp() |
Use only absolute values. For relative calculations, register with type * or omit |
| No re-registration | Attempting to re-register with the same name causes an error | Use a different name entirely if a type change is needed |
| Not usable in query conditions | Cannot be used in container query or media query conditions | Handle state management with data attributes or class-based approaches |
| Main thread animation | Unlike transform and opacity, does not offload to the GPU compositing layer |
Prefer properties that affect only the compositing stage, such as color or opacity |
| Legacy browsers | "Widely Available" designation is scheduled for January 2027 | Apply progressive enhancement after JavaScript feature detection |
Let's dig a bit deeper into main thread animation. transform and opacity are handled by the GPU compositing layer and don't touch the main thread. The reason custom property animations cannot offload to the GPU compositing layer is that the browser cannot statically determine which CSS properties a given variable will affect. Whether --color-primary is used only for background-color or also for border-color is unknown until computation, so style recalculation happens on the main thread every frame.
For legacy browser fallbacks, there is currently no standard way to detect @property support within CSS using @supports. Since syntax is a descriptor of the @property at-rule rather than a CSS property, a query like @supports (syntax: "<color>") does not work correctly. Instead, two approaches are recommended.
Approach 1: Add a class after JavaScript feature detection
if (typeof CSS !== 'undefined' && typeof CSS.registerProperty === 'function') {
document.documentElement.classList.add('supports-at-property');
}/* Base static styles — works in all browsers */
.card {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
}
/* Enhancement for browsers that support @property */
.supports-at-property .card {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
transition: --gradient-start 0.4s ease, --gradient-end 0.4s ease;
}Approach 2: Design with progressive enhancement
Write CSS so that the base styles work completely even without the animation effect, and let @property-based enhancements naturally layer on top.
/* Base styles that always work */
.card {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
}
/* When @property is supported, overrides with variable-based dynamic gradient */
@layer tokens {
@property --gradient-start {
syntax: "<color>";
inherits: false;
initial-value: #3b82f6;
}
@property --gradient-end {
syntax: "<color>";
inherits: false;
initial-value: #8b5cf6;
}
}
@layer components {
.card {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
transition: --gradient-start 0.4s ease, --gradient-end 0.4s ease;
}
}The Most Common Mistakes in Practice
-
Attempting to use dynamic values for
initial-value— Including references to other variables or expressions likeinitial-value: calc(var(--base) * 2)renders the entire@propertydeclaration invalid. The initial value must be a computationally independent absolute value. -
Omitting the
inheritsdescriptor — Writing onlysyntaxandinitial-valuewhile leaving outinheritsinvalidates the entire declaration. All three descriptors are required. -
Attempting to detect
@propertysupport via@supports—@supports (syntax: "<color>")does not work becausesyntaxis not a CSS property. Support detection must be done in JavaScript (typeof CSS.registerProperty === 'function').
Closing Thoughts
@property elevates CSS variables from simple text substitution to typed design tokens. Type safety, interpolatable animations, and inheritance isolation — these three benefits alone are sufficient reason to adopt it.
Three steps you can start with right now:
-
Pick one color token from your existing
:rootCSS variables and re-declare it with@property. Just add the three linessyntax: "<color>",inherits: true, andinitial-value: [existing value], then apply atransitionto that variable and change the color on hover — you can immediately see interpolation in action. -
Introduce
@layer tokensto consolidate your@propertydeclarations in one place. Declare the layer order with@layer tokens, base, components, utilities;and migrate your existing tokens into thetokenslayer — priority management becomes noticeably clearer. -
If you're already using Style Dictionary v4, apply the custom formatter from the example above. If you're managing your token JSON in W3C DTCG format (with
$typefields), a single formatter automates@propertyCSS output and gives you a pipeline from Figma to CSS with type information fully intact.
After adoption, natural follow-up questions emerge as well. How do you inspect registered @property type information and computed values in the Chromium DevTools Styles panel? How do you set code review standards when rolling this out to a team? — Establishing a Stylelint rule that only permits absolute values in initial-value and makes inherits declarations mandatory as a team standard makes it much easier to maintain consistency. I'll continue those practical adoption stories in the next post.
Next post: How to use CSS Typed OM (Typed Object Model) to read and write CSS values in a type-safe way from JavaScript — eliminating numeric calculation errors with
element.attributeStyleMapandCSSUnitValue
References
- @property: Next-gen CSS variables now with universal browser support | web.dev
- @property: giving superpowers to CSS variables | web.dev
- Benchmarking the performance of CSS @property | web.dev
- Smarter custom properties with Houdini's new API | web.dev
- @property CSS at-rule | MDN Web Docs
- CSS Properties and Values API | MDN Web Docs
- Providing Type Definitions for CSS with @property | Modern CSS Solutions
- Exploring @property and its Animating Powers | CSS-Tricks
- The gotcha with @property animating custom properties | Bram.us
- Design Tokens specification reaches first stable version | W3C DTCG
- Style Dictionary — DTCG Support
- Houdini APIs | MDN Web Docs
- CSS Properties and Values API Level 1 | W3C Spec
- Taking a closer look at @property in CSS | utilitybend