Skip to main content

Harshal V. LADHE

Animate and Type-Check CSS Variables with @property

Give CSS custom properties real types so they animate.
Published at:
Last updated:
Estimated reading time:6 min read

Introduction

To the browser, an ordinary custom property is just text. --angle: 45deg looks like an angle, but CSS only finds out what it means when a var() drops it into a real property. So it can't animate, it accepts anything (--angle: banana is valid), and it always inherits with no default.

@property registers a custom property with a type, an inheritance rule and an initial value, so the browser treats it like a built-in property: it can animate it, type-check it and fall back to a known value.

Why Custom Properties Don't Animate

.ring {
  --angle: 0deg;
  background: conic-gradient(from var(--angle), #6366f1, #c084fc, #6366f1);
  transition: --angle 0.7s ease;
}

.ring:hover {
  --angle: 300deg;
}

This looks as if it should rotate the gradient, but it jumps. To animate a value, the browser has to calculate the steps in between, and it can only do that for a type it knows. An unregistered custom property has no type, so a transition changes it instantly, and @keyframes flip it halfway, like discrete properties such as display.

Registering a Property

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

Add this rule and the .ring above rotates smoothly, with no other change. @property is an at-rule, like @media, so it sits at the top level of a stylesheet and applies to the whole document.

DescriptorWhat it setsRequired?
syntaxThe type of value the property acceptsYes
inheritsWhether children receive the valueYes
initial-valueThe value used when nothing sets itYes, unless syntax is "*"

If a required descriptor is missing or invalid, the whole rule is silently ignored and the property stays unregistered — the first thing to check when a registration seems to do nothing.

syntax

SyntaxExample valuesAnimates?
"<length>", "<length-percentage>"12px, 2rem, 50%Yes
"<number>", "<integer>"0.5, 3Yes
"<angle>"45deg, 0.25turnYes
"<time>"200ms, 1.5sYes
"<color>"#6366f1, oklch(0.7 0.15 280)Yes
"<transform-list>"rotate(45deg) scale(1.2)Yes
"<custom-ident>", "<image>"compact, gradientsNo

Types combine: "<length> | auto" for alternatives, "small | large" for keywords, "<length>+" for a space-separated list and "<color>#" for a comma-separated one. "*" accepts anything and never animates.

inherits and initial-value

Unregistered custom properties always inherit. inherits: false keeps a value that belongs to one component — a rotation angle, some internal state — from leaking into everything inside it.

initial-value must be computationally independent: it must mean the same thing on every element. 10px and #6366f1 are fine; 2em and var(--other) aren't, and either one invalidates the rule.

Try It Live

Animate rotates two identical rings, only one of them registered. Count drives a progress ring and its number from one registered <integer>. Type-check gives the same value to a registered <color> and an unregistered property.

@property Playground

Three demos of registered custom properties: a conic-gradient ring that sweeps round beside an unregistered one that jumps, a progress ring and counter driven by one animated <integer>, and a registered <color> falling back to its initial value on input that breaks an unregistered property.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@property Playground</title>
  </head>
  <body>
    <main class="playground">
      <header class="playground-header">
        <h1>Typed Custom Properties with @property</h1>
        <p>Registering a custom property gives it a type, so the browser can animate it, reject values that don't fit, and fall back to a known initial value.</p>
      </header>

      <div class="tabs" role="tablist" aria-label="Demo" id="demo-switch">
        <button class="tab tab--active" type="button" data-mode="animate" role="tab" aria-selected="true">Animate</button>
        <button class="tab" type="button" data-mode="count" role="tab" aria-selected="false">Count</button>
        <button class="tab" type="button" data-mode="validate" role="tab" aria-selected="false">Type-check</button>
      </div>

      <div id="animate-demo" class="tab-panel tab-panel--active">
        <section class="demo-area" aria-label="Typed and untyped rings">
          <div class="rings" id="rings">
            <figure>
              <div class="ring ring--typed" aria-hidden="true"></div>
              <figcaption><code>@property --ring-angle</code><br>registered <code>&lt;angle&gt;</code></figcaption>
            </figure>
            <figure>
              <div class="ring ring--plain" aria-hidden="true"></div>
              <figcaption><code>--plain-angle</code><br>unregistered</figcaption>
            </figure>
          </div>
        </section>

        <button class="toggle-btn" type="button" id="spin-btn">Rotate both to 300deg</button>
        <output class="readout" id="animate-readout" aria-live="polite"></output>
      </div>

      <div id="count-demo" class="tab-panel">
        <section class="demo-area" aria-label="Progress ring driven by one registered integer">
          <div class="stage">
            <div class="progress-ring" id="progress-ring" role="img" aria-label="65% complete"></div>
          </div>
        </section>

        <fieldset class="control-group">
          <legend>--progress</legend>
          <div class="field">
            <label for="progress-input">Target value</label>
            <div class="range-row">
              <span class="range-edge">0</span>
              <div class="range-track">
                <input type="range" id="progress-input" min="0" max="100" step="1" value="65">
                <output class="range-bubble" id="progress-value" for="progress-input">65</output>
              </div>
              <span class="range-edge">100</span>
            </div>
          </div>
        </fieldset>

        <output class="readout" id="count-readout" aria-live="polite"></output>
      </div>

      <div id="validate-demo" class="tab-panel">
        <section class="demo-area" aria-label="Registered and unregistered colour swatches">
          <div class="swatches" id="swatches" data-value="tomato">
            <figure>
              <div class="swatch swatch--typed" aria-hidden="true"></div>
              <figcaption><code>@property --typed-accent</code><br>registered <code>&lt;color&gt;</code></figcaption>
            </figure>
            <figure>
              <div class="swatch swatch--plain" aria-hidden="true"></div>
              <figcaption><code>--plain-accent</code><br>unregistered</figcaption>
            </figure>
          </div>
        </section>

        <fieldset class="control-group">
          <legend>Assign a value</legend>
          <div class="segmented-control" role="radiogroup" aria-label="Value assigned to both properties">
            <label class="segment"><input type="radio" name="accent" value="tomato" checked><span>tomato</span></label>
            <label class="segment"><input type="radio" name="accent" value="#34d399"><span>#34d399</span></label>
            <label class="segment"><input type="radio" name="accent" value="12px"><span>12px</span></label>
            <label class="segment"><input type="radio" name="accent" value="banana"><span>banana</span></label>
          </div>
          <p class="tip" id="validate-tip"></p>
        </fieldset>

        <output class="readout" id="validate-readout" aria-live="polite"></output>
      </div>
    </main>

    <script src="./index.js"></script>
  </body>
</html>

Read-only
Ln –, Col –HTML4.3 KBUTF-8

Starting sandbox…

No console output yet.

What Else Registration Changes

Invalid Values Fall Back

A value that doesn't match the syntax still wins the cascade, but it's invalid at computed-value time, so the property falls back to its inherited value or initial-value:

@property --accent {
  syntax: "<color>";
  inherits: false;
  initial-value: #6366f1;
}

.card {
  --accent: tomato;
  color: var(--accent);
}

.card.is-broken {
  --accent: 12px;
} /* not a <color>: --accent becomes #6366f1, not tomato */

Unregistered, --accent would store 12px and break color: var(--accent) instead. Registered, every var() that reads it always gets a colour. This is also how registration gives typed attr() a clean fallback in unsupporting browsers.

Values Are Computed Where They're Set

An unregistered property passes its text down unchanged. A registered one is computed first, so with --gap: 1em on a 20px parent, children inherit 20px, not 1em resolved against their own font size.

Patterns Worth Knowing

Animating Gradients

Gradients can't be transitioned, but registered <angle> and <color> properties inside them can — the technique behind rotating conic borders and animated gradient text.

Counting Numbers

An <integer> animates in whole steps, and counter() can print it:

@property --value {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}

.stat {
  counter-reset: stat var(--value);
  transition: --value 1.5s ease-out;
}

.stat::after {
  content: counter(stat) "%";
}

.stat.is-visible {
  --value: 72;
}

Generated content isn't reliably read by screen readers, so keep the final value in real text or an aria-label.

Separate Timing for Each Axis

Two registered lengths split one translate into parts with their own timing — here a linear horizontal motion and an eased vertical one trace an arc:

@property --x {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --y {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

.ball {
  translate: var(--x) var(--y);
  animation:
    move-x 2s linear infinite alternate,
    move-y 1s ease-in-out infinite alternate;
}

@keyframes move-x {
  to {
    --x: 16rem;
  }
}
@keyframes move-y {
  to {
    --y: -6rem;
  }
}

Stop infinite animations like this inside @media (prefers-reduced-motion: reduce).

Registering from JavaScript

CSS.registerProperty({
  name: "--angle",
  syntax: "<angle>",
  inherits: false,
  initialValue: "0deg"
});

It does the same job as @property. Prefer the CSS rule; use this when the name is only known at run time. Calling it twice with the same name throws an InvalidModificationError, so guard code that may run more than once.

Performance

Unlike transform or opacity, an animated custom property recalculates styles, and repaints gradients, on the main thread every frame — not on the GPU. That's negligible for a button, but can show across dozens of large elements. Prefer inherits: false, which limits the work to one element, and use translate directly unless you need per-axis timing.

Browser Support and Fallbacks

@property has been Baseline since July 2024: Chrome and Edge 85, Safari 16.4 and Firefox 128. Older browsers ignore the rule, so the property behaves as if it were unregistered. Make that acceptable rather than broken:

  • Set a starting value on the element or :root, not only as initial-value.
  • Add a var() fallback where a missing value would break the declaration: var(--angle, 0deg).
  • Let animations degrade to a jump — a gradient that changes instantly is fine in an old browser.

Common Pitfalls

1. Name Collisions

Registration is global, so the last valid rule for a name wins. Namespace component properties: --ring-angle, --stat-value.

2. Expecting inherits: false Values to Reach Children

Children get the initial-value, not their parent's value. Use inherits: true or set the property on the child.

3. Registering Inside Shadow DOM

Browsers don't consistently apply @property rules inside a shadow root. Register from a document stylesheet or with CSS.registerProperty() instead.

When to Use This

Register a custom property when it drives an animation, when it should only ever hold one type (a colour token, for example), or when it's component state that shouldn't inherit.

Leave it unregistered when it only passes a list or shorthand through to var(), or must resolve em or % against each element that reads it.

Key Takeaway

An ordinary custom property is text: it can't animate, it accepts anything, and it always inherits. @property gives it a syntax, an inherits rule and an initial-value, so the browser can animate it and fall back safely when a value is wrong. Register the properties that animate or must stay one type, namespace their names, and keep a var() fallback for browsers that ignore the rule.

Changelog

  • — Initial publication.
This tutorial is licensed under CC BY 4.0 by the author.

Share this tutorial

Spotted a mistake or something outdated in this tutorial? Suggest a correction.

  • HTML Attributes as Real CSS Values with Typed attr()

    Read HTML attributes as CSS colours, lengths and numbers.
    Typed attr() reads an HTML attribute as a real CSS value — type(<color>), type(<integer>), a unit like % or px, or raw-string — for any property on any element, not just text in content. Covers fallbacks for missing or invalid values, patterns for layout, transitions and view transitions, safe fallbacks in browsers without support, and when a custom property is the better choice.
    Published at:
  • Squircles, Bevels and Scoops with corner-shape

    Shape corners into squircles, bevels, scoops and notches.
    The corner-shape property gives border-radius corners a shape — round, squircle, square, bevel, scoop, notch or any superellipse() — with borders, shadows and outlines that follow it, per-corner values, smooth animation, and an automatic rounded fallback in browsers without support.
    Published at:
  • CSS-Only Staggered Animations with sibling-index() and sibling-count()

    Stagger animations from each element's own position, without JavaScript.
    sibling-index() and sibling-count() give an element its own 1-based position and its sibling total directly inside calc() — replacing the JavaScript loop that sets a --i custom property, with fixed-step and fixed-total staggers, index-driven colours and rotations, fallbacks, and reduced-motion handling.
    Published at: