CSS @property: Typed Custom Properties and Animatable Variables in WordPress

CSS @property is an at-rule that registers a custom property with a type, initial value, and inheritance behaviour — transforming it from an opaque string substitution (which is what unregistered --custom-property variables are) into a typed CSS value that the browser understands as a specific <color>, <length>, <number>, <percentage>, <angle>, <time>, or other CSS type. The most impactful consequence of registering a custom property: the browser can interpolate it in @keyframes and CSS transitions, enabling animations that are impossible with unregistered variables. An unregistered variable like --hue: 0 animated to --hue: 360 inside @keyframes is treated as two discrete string values — the browser switches from one to the other at the 50% mark with no interpolation. A registered @property --hue { syntax: "<number>"; inherits: false; initial-value: 0; } is animated as a number — the browser interpolates all 360 values smoothly between 0 and 360. The syntax descriptor defines the type using CSS value definition syntax: <color>, <length>, <number>, <percentage>, <integer>, <angle>, <resolution>, <transform-list>, <custom-ident>, or * (any, same as unregistered); the pipe | character combines types (<number> | <percentage>). The inherits descriptor controls whether the property’s value cascades from parent to child elements — false means each element gets its own initial value, which is essential for per-element animated properties used on multiple elements simultaneously. initial-value is required when inherits: false — it defines the starting value for elements that do not set the property explicitly. Browser support: Chrome 85+, Edge 85+, Safari 16.4+, Firefox 128+ — as of 2024 all evergreen browsers support @property, making it safe for WordPress themes targeting modern browsers. In WordPress themes, @property registered in style.css or a stylesheet enqueued via wp_enqueue_style() can create animatable gradient angles, hue-rotation effects, and counter animations that previously required JavaScript. The scroll-driven animations post used animation-timeline for scroll-triggered effects; @property provides the typed variable layer that makes those animations interpolate custom values smoothly.

Problem: A WordPress block theme uses a gradient hero banner whose angle is set as a CSS custom property (--hero-angle: 135deg) to allow easy customisation. Adding a CSS animation to rotate the gradient angle from 0deg to 360deg produces only two states (0% and 100% keyframes snap) because the browser treats --hero-angle as a string, not an angle.

Solution: Register --hero-angle as a typed @property with syntax: "<angle>", then use a standard @keyframes animation — the browser now interpolates the angle smoothly across all 360 degrees without any JavaScript.

/* ── Register typed custom properties ──────────── */

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

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

@property --card-progress {
    syntax:        "<percentage>";
    inherits:      false;
    initial-value: 0%;
}

/* ── Animated gradient hero using @property ──────────── */

.wp-block-cover.is-style-animated-gradient {
    /* @keyframes can now interpolate --hero-angle as a real angle */
    animation: rotate-gradient 8s linear infinite;
    background-image: linear-gradient(
        var(--hero-angle),
        oklch(55% 0.2 var(--hero-hue)),
        oklch(40% 0.25 calc(var(--hero-hue) + 60))
    );
}

@keyframes rotate-gradient {
    from {
        --hero-angle: 0deg;
        --hero-hue:   240;
    }
    to {
        --hero-angle: 360deg;
        --hero-hue:   300;
    }
}

/* ── Per-element progress bar without JavaScript ──────────── */

/* Each .progress-bar element has its own --card-progress (inherits: false) */
.progress-bar {
    /* The data attribute value is injected via PHP inline style:             */
    /* <div class="progress-bar" style="--card-progress: 72%">               */
    background: linear-gradient(
        to right,
        var(--wp--preset--color--primary) var(--card-progress),
        #e5e7eb var(--card-progress)
    );
    height: 8px;
    border-radius: 4px;
    /* Animate from 0% to the target on page load */
    animation: fill-progress 1s ease-out forwards;
}

@keyframes fill-progress {
    from { --card-progress: 0%; }
    /* 'to' is the element's own --card-progress value set via inline style  */
    to   { --card-progress: var(--card-progress); }
}

/* ── Typed property for a hover colour transition ──────────── */

@property --btn-hue {
    syntax:        "<number>";
    inherits:      false;
    initial-value: 221;   /* blue */
}

.wp-block-button__link {
    --btn-hue:        221;
    background-color: oklch(55% 0.22 var(--btn-hue));
    /* Transition now interpolates hue numerically through OKLCH colour space */
    transition: --btn-hue 300ms ease;
}

.wp-block-button__link:hover {
    --btn-hue: 150;   /* smoothly transitions hue from blue to green */
}

// Render progress bars in PHP with inline --card-progress custom property
function render_skill_bars( array $skills ): string {
    $output = '<div class="skill-bars">';
    foreach ( $skills as $name => $pct ) {
        $pct     = min( 100, max( 0, (int) $pct ));
        $output .= sprintf(
            '<div class="skill-bar-wrap">'
            . '<span class="skill-label">%s</span>'
            . '<div class="progress-bar" style="--card-progress: %d%%"'
            . '     role="progressbar" aria-valuenow="%d" aria-valuemin="0" aria-valuemax="100">'
            . '</div></div>',
            esc_html( $name ),
            $pct, $pct
        );
    }
    return $output . '</div>';
}

NOTE: When using @property with inherits: false for per-element animation (like the progress bar above), every element that uses the property gets its own value starting at initial-value — this is exactly what makes the pattern work: 20 progress bars on the same page all animate independently from 0% to their own target without any JavaScript. If you set inherits: true, child elements inherit the parent’s value and a change on a parent propagates to all children, which is useful for theme-wide typed design tokens (colours, spacing) but wrong for per-element animations. Also, @property must be declared at the top level of a stylesheet — it cannot be nested inside a selector rule or a @layer block in browsers that do not yet support nested @property (Chrome 117+ supports it; other browsers require top-level declaration).