← Notes
18 Jun 2024 CSS

@property: the one that makes custom properties actually animate

CSS custom properties can't animate because the browser doesn't know their type. @property declares the type. After that, they animate, transition and interpolate like native values.

  • #css
  • #animation
  • #custom-properties
  • #property

At some point you tried to animate a CSS custom property with a transition and nothing happened:

.element {
  --glow-opacity: 0;
  box-shadow: 0 0 20px rgba(196, 82, 42, var(--glow-opacity));
  transition: --glow-opacity 300ms ease;
}

.element:hover {
  --glow-opacity: 0.8;
}

The browser jumped from 0 to 0.8. No interpolation. The transition did nothing.

The reason: CSS custom properties are opaque to the browser. Without knowing the type, the browser can’t interpolate. Interpolating between “apple” and “orange” is undefined. So it skips it.

@property registers the custom property with a type:

@property --glow-opacity {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

.element {
  box-shadow: 0 0 20px rgba(196, 82, 42, var(--glow-opacity));
  transition: --glow-opacity 300ms ease;
}

.element:hover {
  --glow-opacity: 0.8;
}

Now the browser knows --glow-opacity is a number. It interpolates. The transition works.

Browser support: Baseline 2024. Chrome 85+ (October 2020), Safari 16.4+ (March 2023), Firefox 128+ (June 2024). MDN reference.

A rotating gradient without JavaScript

@property --gradient-angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

@keyframes spin-gradient {
  to { --gradient-angle: 360deg; }
}

.spinning-border {
  background: conic-gradient(
    from var(--gradient-angle),
    var(--rust, #c4522a) 0deg,
    var(--paper, #faf7f4) 90deg,
    var(--rust, #c4522a) 180deg,
    var(--paper, #faf7f4) 270deg,
    var(--rust, #c4522a) 360deg
  );
  animation: spin-gradient 4s linear infinite;
}

Without @property, --gradient-angle can’t be animated — the browser doesn’t know it’s an angle. With it, the conic-gradient spins smoothly. No JavaScript. No requestAnimationFrame.

@property --gradient-angle { syntax: '<angle>' }
No JS. No rAF. CSS animates the angle directly.

A progress ring

@property --ring-progress {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

.progress-ring {
  --ring-progress: 0;
  background: conic-gradient(
    var(--accent) calc(var(--ring-progress) * 1%),
    var(--surface-muted) 0
  );
  border-radius: 50%;
  width: 80px;
  height: 80px;
  transition: --ring-progress 600ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
// Update progress — one property, eased transition
ring.style.setProperty('--ring-progress', '75');

The conic-gradient updates with an eased transition. The visual is a smooth-filling arc. One property update, one line of JavaScript.

The syntax values you’ll actually use

@property --my-color   { syntax: '<color>';      inherits: true;  initial-value: black; }
@property --my-length  { syntax: '<length>';     inherits: false; initial-value: 0px; }
@property --my-number  { syntax: '<number>';     inherits: false; initial-value: 0; }
@property --my-angle   { syntax: '<angle>';      inherits: false; initial-value: 0deg; }
@property --my-percent { syntax: '<percentage>'; inherits: false; initial-value: 0%; }

The inherits flag matters: if true, the property follows the CSS cascade like color or font-size. If false, it doesn’t inherit from parent to child — useful for properties that are component-local.

The four years we spent waiting for Firefox is, in the way of these things, already starting to feel like it happened to someone else.

Ship it.