Parameterising Keyframes with Custom Properties

Part of Keyframe Architecture & State Mapping in Core CSS Animation Fundamentals.

The problem

A stylesheet contains slide-in-left, slide-in-right, slide-in-up, slide-in-down, then slide-in-left-small and slide-in-left-large, then pop-in and pop-in-subtle. Each is a near-copy of another with a different number. Changing the easing feel means editing twelve keyframe rules, and the reduced-motion override has to list every animation name.

Keyframes cannot take arguments. They can, however, read custom properties — and that is almost as good.

Root cause analysis: var() in keyframes resolves per element

A @keyframes rule is a template. When an element runs the animation, each keyframe’s declarations are computed in the context of that element, so var(--slide-x) inside a keyframe reads the element’s own --slide-x. Two elements running the same slide-in animation with different --slide-x values travel different distances.

Unregistered properties are substituted once. For an ordinary custom property, the value is substituted into the keyframe when the animation’s keyframes are resolved. Changing --slide-x while the animation runs may cause the keyframes to be recomputed, which can make the motion jump. Treat parameters as fixed for the duration of one run.

Registered properties behave differently when animated. If --slide-x is registered with @property and something else animates --slide-x at the same time, the keyframes read the animated value each frame. That is powerful — it is how composition with registered properties works — but for plain parameters it just adds per-frame style work. Parameters that only configure an animation should stay unregistered.

Fallbacks make keyframes safe. var(--slide-x, 0px) means an element with no parameters still gets a valid animation. Without a fallback, a missing property makes the declaration invalid at computed-value time and the property falls back to its initial value — typically none for translate, which silently removes the motion.

How one keyframes rule serves many variantsThe template is shared; values come from each element.How one keyframes rule serves many variants@keyframesslide-invar() placeholdersElement A--slide-x: -24pxElement B--slide-y: 16pxResolved perelementat startCompositedmotion
The template is shared; values come from each element.

Step-by-step resolution

Collapsing duplicated keyframesOne template per motion shape, parameters per use.Collapsing duplicated keyframes1Group existing keyframes by shape: slide, pop, fade.A handful of templates instead of dozens2Rewrite each template with var() and sensible fallbacks.Works with or without parameters3Create variant classes that only set parameters.--from-left sets --slide-x negative, and so on4Point components at the template and a variant.Motion changes in one place5Leave parameters unregistered.No per-frame style cost6Zero distances in one reduced-motion rule.
One template per motion shape, parameters per use.

Production code pattern

/* Motion templates: one per shape. */
@keyframes slide-in {
  from {
    opacity: var(--slide-fade, 0);
    translate: var(--slide-x, 0px) var(--slide-y, 0px);
  }
}
@keyframes pop-in {
  from {
    opacity: 0;
    scale: var(--pop-from, 0.9);
  }
}

/* Shared timing, overridable per component. */
.motion-slide {
  animation: slide-in var(--motion-duration, 240ms) var(--motion-ease, cubic-bezier(0.2, 0.8, 0.2, 1)) backwards;
}
.motion-pop {
  animation: pop-in var(--motion-duration, 200ms) var(--motion-ease, cubic-bezier(0.34, 1.4, 0.64, 1)) backwards;
}

/* Variants only set parameters. */
.from-left   { --slide-x: -24px; }
.from-right  { --slide-x: 24px; }
.from-below  { --slide-y: 16px; }
.subtle      { --slide-x: calc(var(--slide-x, 0px) * 0.5); --pop-from: 0.97; }

/* One reduced-motion rule covers every variant. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --slide-x: 0px !important;
    --slide-y: 0px !important;
    --pop-from: 1 !important;
    --motion-duration: 150ms;
  }
}
<aside class="drawer motion-slide from-right">…</aside>
<li class="toast motion-slide from-below subtle">…</li>
<dialog class="motion-pop">…</dialog>

Rendering Impact: composite. Parameters are resolved when each animation starts; the frames themselves animate only opacity, translate and scale. Because the parameters are unregistered and static, they add no per-frame style work.

The reduced-motion block shows the payoff. Instead of listing every animation name, it neutralises the parameters: every slide becomes a fade and every pop becomes a fade, with a shorter duration, across the whole codebase. !important on the custom properties ensures variant classes cannot re-introduce travel.

The .subtle modifier reading var(--slide-x) in its own definition is a circular reference if set on the same element as the variant; in practice, apply modifiers through a distinct property such as --slide-scale multiplied inside the keyframe. The example keeps it short, but real systems should avoid self-referencing custom properties.

Keyframe rules in a design system before and after parameterisingSame visual variants, same reduced-motion coverage.Keyframe rules in a design system before and after parameterisingBefore: one rule per variant23 rulesAfter: one rule per motion shape4 rules
Same visual variants, same reduced-motion coverage.

Verification checklist

Constraints and trade-offs

  • Changing a parameter mid-animation can recompute keyframes and cause a jump.
  • DevTools shows keyframes with var() references, which is less immediately readable than literal values.
  • Self-referencing custom properties are invalid; use separate multiplier properties for modifiers.
  • Registered parameters cost style work per frame when animated; keep configuration properties unregistered.
  • !important on custom properties in the reduced-motion block overrides every later declaration, by design.

Documenting and debugging parameterised motion

A template is only reusable if people know its parameters. Keep a short comment block above each keyframes rule listing the custom properties it reads, their units and their defaults — the keyframes rule is the API. A component that sets --slide-distance instead of --slide-x fails silently, because the fallback applies and the element simply does not move.

In DevTools, the Animations drawer shows the resolved keyframe values for the selected element when you click an animation group, which is the quickest way to confirm what a variant actually produced. The Computed tab lists the element’s custom properties, so a missing or misspelt parameter is visible as an absent entry. For a design system, a Storybook-style page that renders every variant side by side catches parameter drift far earlier than production does.

Parameterised keyframes also combine well with motion tokens. Distances, durations and easings then all come from named values, and a brand refresh that makes motion calmer is a change to a handful of tokens rather than a hunt through keyframes.

Frequently asked questions

Can @keyframes use CSS variables?

Yes. Custom properties inside keyframes resolve against each animating element, so one keyframes rule can produce different motion per element.

What happens if the variable is not set?

Without a fallback, the declaration becomes invalid at computed-value time and the property uses its initial value, which usually removes the motion. Always provide a fallback in var().

Should parameter properties be registered with @property?

Not unless they are animated. Registration is for values that interpolate; static parameters work fine unregistered and avoid per-frame style work.

How does this help reduced motion?

Setting all distance parameters to zero in one media query turns every travelling animation into a fade without naming individual animations.