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:        "";
    inherits:      false;
    initial-value: 135deg;
}

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

@property --card-progress {
    syntax:        "";
    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:             */
    /* 
*/ 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: ""; 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 = '
'; foreach ( $skills as $name => $pct ) { $pct = min( 100, max( 0, (int) $pct )); $output .= sprintf( '
' . '%s' . '
' . '
', esc_html( $name ), $pct, $pct ); } return $output . '
'; }

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).