MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

CSS @property: Typed Custom Properties

Use @property for animated custom properties: syntax, inheritance, and animations.

CSS@propertyCustom PropertiesFrontend

By MinhVo

Introduction

CSS custom properties (variables) have been a game-changer for theming, design tokens, and dynamic styling. But they have always had a significant limitation: they are treated as untyped strings. The browser does not know that --primary-hue is a number, that --brand-color is a color, or that --spacing is a length. This lack of type information means custom properties cannot be animated with CSS transitions or animations — the browser cannot interpolate between two unknown values.

The @property at-rule solves this by letting you register custom properties with explicit types, initial values, and inheritance behavior. Once registered, the browser knows exactly what a property represents, enabling smooth transitions, gradient interpolation, and even mathematical operations. This opens up design possibilities that previously required JavaScript: color palette animations, smooth theme transitions, animated gradients, and computed design tokens.

This guide covers the complete @property specification, demonstrates every supported type, and shows production-ready patterns for animating custom properties in real-world projects.

CSS @property illustration

Understanding @property: Core Concepts

Registering a Custom Property

The @property at-rule registers a custom property with the browser's CSS engine:

@property --primary-hue {
  syntax: "<number>";
  inherits: false;
  initial-value: 220;
}

Three required fields:

  • syntax: The type of the property's value. Uses the CSS Value Definition Syntax.
  • inherits: Whether the property inherits from parent elements (like color) or is scoped (like border).
  • initial-value: The default value when the property is not set.

Supported Syntax Types

The syntax field accepts these type strings:

SyntaxDescriptionExample Values
"<number>"A numeric value0, 42, 3.14
"<integer>"A whole number0, 42, -1
"<length>"A length with unit16px, 2rem, 0
"<percentage>"A percentage50%, 100%
"<length-percentage>"Length or percentage16px, 50%
"<color>"Any CSS color#ff0000, rgb(255,0,0), red
"<angle>"An angle45deg, 1rad, 0.5turn
"<time>"A time value0.3s, 500ms
"<resolution>"Resolution2dppx, 192dpi
"<transform>"A transform functionrotate(45deg)
"<custom-ident>"A custom identifierprimary, secondary
"*"Any valid CSS valueAnything

The "*" wildcard syntax behaves like an unregistered custom property — it accepts any value but cannot be animated.

Why Typing Enables Animation

Without @property, this transition does nothing:

.box {
  --hue: 0;
  background: hsl(var(--hue), 80%, 50%);
  transition: --hue 1s;
}
 
.box:hover {
  --hue: 200;
}

The browser cannot interpolate --hue because it does not know it is a number. With @property:

@property --hue {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}
 
.box {
  background: hsl(var(--hue), 80%, 50%);
  transition: --hue 1s ease;
}
 
.box:hover {
  --hue: 200;
}

Now the browser knows --hue is a number and smoothly animates from 0 to 200.

Animation comparison

Architecture and Design Patterns

Pattern 1: Animated Color Themes

@property --theme-hue {
  syntax: "<number>";
  inherits: true;
  initial-value: 220;
}
 
:root {
  --theme-hue: 220;
  --theme-saturation: 70%;
  --theme-lightness: 50%;
 
  --brand: hsl(var(--theme-hue), var(--theme-saturation), var(--theme-lightness));
  --brand-light: hsl(var(--theme-hue), var(--theme-saturation), 90%);
  --brand-dark: hsl(var(--theme-hue), var(--theme-saturation), 20%);
}
 
.theme-transition {
  transition: --theme-hue 0.6s ease;
}
 
.theme-blue { --theme-hue: 220; }
.theme-green { --theme-hue: 150; }
.theme-red { --theme-hue: 0; }

Pattern 2: Gradient Animation

@property --gradient-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
 
@property --gradient-start {
  syntax: "<color>";
  inherits: false;
  initial-value: #ff6b6b;
}
 
@property --gradient-end {
  syntax: "<color>";
  inherits: false;
  initial-value: #4ecdc4;
}
 
.gradient-box {
  background: linear-gradient(
    var(--gradient-angle),
    var(--gradient-start),
    var(--gradient-end)
  );
  transition:
    --gradient-angle 0.8s ease,
    --gradient-start 0.8s ease,
    --gradient-end 0.8s ease;
}
 
.gradient-box:hover {
  --gradient-angle: 180deg;
  --gradient-start: #a855f7;
  --gradient-end: #3b82f6;
}

Pattern 3: Progress Indicator

@property --progress {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}
 
.progress-bar {
  --progress: 0%;
  background: linear-gradient(
    to right,
    var(--brand) 0%,
    var(--brand) var(--progress),
    #e5e7eb var(--progress),
    #e5e7eb 100%
  );
  transition: --progress 0.6s ease;
}
 
.progress-bar[data-complete] {
  --progress: 100%;
}

Pattern 4: Animated Counters

@property --count {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}
 
.counter {
  --count: 0;
  transition: --count 2s ease-out;
  counter-reset: count var(--count);
}
 
.counter::after {
  content: counter(count);
  font-size: 3rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
 
.counter[data-target="100"] {
  --count: 100;
}

Step-by-Step Implementation

Step 1: Identify Animatable Custom Properties

Search your codebase for custom properties used in colors, numbers, or measurements that would benefit from animation:

grep -r 'var(--' src/styles/ | grep -o 'var(--[a-z-]*)' | sort | uniq -c | sort -rn

Step 2: Register Properties with @property

Create a dedicated file for property registrations:

/* properties.css */
@property --hue {
  syntax: "<number>";
  inherits: true;
  initial-value: 220;
}
 
@property --saturation {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 70%;
}
 
@property --lightness {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}
 
@property --border-radius {
  syntax: "<length>";
  inherits: false;
  initial-value: 8px;
}
 
@property --opacity {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}

Step 3: Add Transitions

.theme-switcher {
  transition:
    --hue 0.5s ease,
    --saturation 0.5s ease,
    --lightness 0.5s ease;
}

Step 4: Use in Component Styles

.button {
  background: hsl(var(--hue), var(--saturation), var(--lightness));
  border-radius: var(--border-radius);
  opacity: var(--opacity);
}
 
.button:hover {
  --lightness: 45%;
  --border-radius: 12px;
}

Step 5: JavaScript Integration

// Animate via JavaScript
const element = document.querySelector('.animated');
element.style.setProperty('--progress', '75%');
 
// The CSS transition handles the animation
// No requestAnimationFrame needed

Step 6: Check Browser Support and Provide Fallbacks

/* Fallback for browsers without @property support */
@supports not (background: paint(something)) {
  .gradient-box {
    background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
  }
}
 
/* Enhanced version with animation */
@property --gradient-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 45deg;
}
 
.gradient-box {
  background: linear-gradient(
    var(--gradient-angle),
    #ff6b6b,
    #4ecdc4
  );
  transition: --gradient-angle 0.8s ease;
}

Implementation workflow

Real-World Use Cases

Use Case 1: Smooth Theme Switching

Instead of an instant color swap, animate the entire theme transition:

@property --brand-hue {
  syntax: "<number>";
  inherits: true;
  initial-value: 220;
}
 
:root {
  transition: --brand-hue 0.4s ease;
}
 
:root[data-theme="ocean"] { --brand-hue: 200; }
:root[data-theme="forest"] { --brand-hue: 140; }
:root[data-theme="sunset"] { --brand-hue: 20; }

Every element using --brand-hue in its colors smoothly transitions.

Use Case 2: Scroll-Linked Progress Bar

@property --scroll-progress {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}
 
.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: linear-gradient(
    to right,
    var(--brand) var(--scroll-progress),
    transparent var(--scroll-progress)
  );
  transition: --scroll-progress 0.1s linear;
}
window.addEventListener('scroll', () => {
  const progress = (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;
  document.querySelector('.reading-progress')
    .style.setProperty('--scroll-progress', `${progress}%`);
});

Use Case 3: Animated Card Hover Effects

@property --card-shadow-size {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}
 
@property --card-shadow-opacity {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}
 
.card {
  box-shadow: 0 var(--card-shadow-size) calc(var(--card-shadow-size) * 3)
    rgba(0, 0, 0, var(--card-shadow-opacity));
  transition:
    --card-shadow-size 0.3s ease,
    --card-shadow-opacity 0.3s ease;
}
 
.card:hover {
  --card-shadow-size: 12px;
  --card-shadow-opacity: 0.15;
}

Use Case 4: Loading Spinner with Animated Hue

@property --spinner-hue {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}
 
.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid hsl(var(--spinner-hue), 80%, 90%);
  border-top-color: hsl(var(--spinner-hue), 80%, 50%);
  border-radius: 50%;
  animation: spin 1s linear infinite, hue-shift 3s linear infinite;
}
 
@keyframes spin {
  to { transform: rotate(360deg); }
}
 
@keyframes hue-shift {
  to { --spinner-hue: 360; }
}

Best Practices for Production

  1. Register properties in a dedicated file: Keep all @property declarations in one place (e.g., properties.css) imported at the top of your stylesheet. This makes it easy to see all typed properties at a glance.

  2. Set inherits: true for theme properties: Properties like --hue and --saturation should inherit so the theme applies to all descendants. Set inherits: false for element-specific properties like --card-shadow-size.

  3. Always provide initial-value: Without it, the property is invalid when unset, which can break animations. The initial value should match your default theme.

  4. Use transitions, not animations, for user-triggered changes: transition is ideal for hover states and theme switches. Use @keyframes animations for continuous effects (spinning, pulsing).

  5. Keep syntax types accurate: Registering --hue as "<length>" instead of "<number>" will cause all animations to fail silently. Double-check type matches.

  6. Provide fallbacks: Not all browsers support @property. Use @supports to provide static fallbacks for unsupported browsers.

  7. Avoid registering too many properties: Each @property registration has a small memory cost. Register only properties that need animation or type checking.

  8. Combine with CSS custom properties for design tokens: Register your design token primitives (hue, saturation, spacing unit) and derive computed tokens from them.

Common Pitfalls and Solutions

PitfallImpactSolution
Wrong syntax typeAnimation fails silentlyDouble-check the type matches the actual values
Missing initial-valueProperty is invalid when unsetAlways provide a sensible default
Forgetting inherits: true for themesTheme does not apply to child elementsSet inherits: true for global properties
Animating with @keyframes without @propertyNo animation occursRegister the property before using it in keyframes
Using * wildcard syntaxBehaves like unregistered — no animationUse specific types like <number>, <color>
Registering inside @media or @layer@property must be at the top levelKeep registrations at the root level

The Silent Failure Problem

If your @property animation does not work, the most common cause is a syntax mismatch. For example, registering --hue with syntax: "<length>" but using values like 220 (no unit). Lengths require units (220px), but hue is unitless. Use syntax: "<number>" for unitless numeric values.

Performance Optimization

@property animations are handled entirely by the browser's compositor, which means they run on the GPU and do not trigger layout or paint. This makes them significantly more performant than JavaScript-driven animations:

  • No JavaScript frame loop: The browser handles interpolation natively.
  • Compositor-thread execution: Animations run off the main thread.
  • No layout thrashing: Typed property changes do not trigger reflow.

For best performance, animate only compositor-friendly properties (transform, opacity, and registered custom properties used in filter or background). Avoid animating properties that trigger layout (width, height, margin).

Comparison with Alternatives

ApproachPerformanceFlexibilityBrowser SupportComplexity
@property + CSS transitionGPU-acceleratedCSS-native95%+ (2024)Low
JavaScript requestAnimationFrameMain threadFull controlUniversalMedium
CSS @keyframes (static)GPU-acceleratedPredefined onlyUniversalLow
GreenSock / Framer MotionDepends on propertiesFull controlUniversalMedium
Houdini Paint APIGPU-acceleratedCustom renderingLimitedHigh

@property fills the gap between static CSS animations and full JavaScript animation libraries. For simple property interpolations, it is the most performant and declarative option.

Advanced Patterns

Polymorphic Type with Multiple Registrations

/* Different types for different contexts */
@property --size {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}
 
/* Re-register with percentage for responsive contexts */
/* Note: only one registration is active; use a different name for different types */
@property --size-pct {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}

Multi-Property Coordinated Animation

@property --morph-radius {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 50%;
}
 
@property --morph-rotate {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
 
.shape {
  width: 100px;
  height: 100px;
  background: var(--brand);
  border-radius: var(--morph-radius);
  transform: rotate(var(--morph-rotate));
  transition:
    --morph-radius 0.8s cubic-bezier(0.34, 1.56, 0.64, 1),
    --morph-rotate 0.8s ease;
}
 
.shape:hover {
  --morph-radius: 10%;
  --morph-rotate: 45deg;
}

Keyframe Animation with Typed Properties

@property --pulse-opacity {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}
 
.pulse {
  animation: pulse 2s ease-in-out infinite;
}
 
@keyframes pulse {
  0%, 100% { --pulse-opacity: 1; }
  50% { --pulse-opacity: 0.5; }
}

Testing Strategies

import { test, expect } from '@playwright/test';
 
test('property animation transitions smoothly', async ({ page }) => {
  await page.goto('/demo');
  const box = page.locator('.animated-box');
  
  // Hover to trigger animation
  await box.hover();
  
  // Wait for transition to start
  await page.waitForTimeout(100);
  
  // Check intermediate value (should be transitioning)
  const midValue = await box.evaluate(el => 
    getComputedStyle(el).getPropertyValue('--hue')
  );
  expect(parseFloat(midValue)).toBeGreaterThan(0);
  expect(parseFloat(midValue)).toBeLessThan(200);
  
  // Wait for transition to complete
  await page.waitForTimeout(600);
  const endValue = await box.evaluate(el => 
    getComputedStyle(el).getPropertyValue('--hue')
  );
  expect(parseFloat(endValue)).toBe(200);
});

Future Outlook

The @property specification is stable and broadly supported. The CSS Houdini Working Group is exploring extensions including:

  • Computed @property in JavaScript: Accessing registered properties via the Typed OM.
  • Property groups: Registering multiple related properties in a single block.
  • Custom syntax definitions: Allowing authors to define their own syntax types beyond the built-in set.

The combination of @property, CSS Anchor Positioning, and the Popover API represents a new era where CSS handles interactions that previously required JavaScript libraries.

Cross-Browser Testing Strategy

Modern CSS features often have varying levels of browser support, making a systematic cross-browser testing strategy essential. Before using any CSS feature in production, verify its support status and implement appropriate fallbacks for browsers that haven't yet implemented it.

Progressive Enhancement with @supports

Use the @supports at-rule to provide fallback styles for browsers that don't support specific CSS features:

/* Base styles for all browsers */
.grid-container {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}
 
.grid-container > * {
  flex: 1 1 300px;
}
 
/* Enhanced layout for browsers supporting CSS Grid */
@supports (display: grid) {
  .grid-container {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 1.5rem;
  }
 
  .grid-container > * {
    flex: none;
  }
}
 
/* Further enhancement with subgrid support */
@supports (grid-template-columns: subgrid) {
  .grid-container {
    grid-template-columns: subgrid;
  }
}

Visual Regression Testing

Implement visual regression testing to catch unintended layout shifts and styling changes. Tools like Percy, Chromatic, or Playwright's screenshot comparison can detect visual differences across browsers and screen sizes:

const { test, expect } = require('@playwright/test');
 
test('responsive layout matches design', async ({ page }) => {
  await page.goto('/components/dashboard');
 
  // Test at multiple viewport sizes
  for (const viewport of [
    { width: 375, height: 812, name: 'mobile' },
    { width: 768, height: 1024, name: 'tablet' },
    { width: 1440, height: 900, name: 'desktop' },
  ]) {
    await page.setViewportSize({ width: viewport.width, height: viewport.height });
    await expect(page).toHaveScreenshot(
      `dashboard-${viewport.name}.png`,
      { maxDiffPixels: 100 }
    );
  }
});

Browser Compatibility Testing Matrix

Maintain a testing matrix that covers the browsers and versions your users actually use. Use analytics data to determine your browser support baseline, then configure tools like Browserslist to automatically handle polyfilling and prefixing:

{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

This data-driven approach ensures you're spending testing effort where it matters most, rather than trying to support every possible browser configuration.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

@property transforms custom properties from opaque strings into typed, animatable values. This unlocks smooth transitions for colors, gradients, counters, and any numeric CSS value — all without JavaScript.

Key takeaways:

  1. Register properties that need animation with @property and the correct syntax type.
  2. Use inherits: true for global theme properties and inherits: false for element-scoped ones.
  3. Always provide initial-value to prevent invalid states.
  4. Animate with CSS transition for user-triggered changes and @keyframes for continuous effects.
  5. Combine with HSL color functions for dynamic, themeable palettes that animate smoothly.
  6. Provide @supports fallbacks for browsers that do not support @property.

Start by registering your primary brand color's hue component and adding a transition to it. The smooth theme-switching effect you get with zero JavaScript will convince you to adopt @property throughout your design system.