Reduced-Motion Fallback Patterns for Keyframes

Part of prefers-reduced-motion Architecture in Accessible Motion Architecture.

The problem: one reset is not one answer

The global reduced-motion reset collapses every @keyframes animation to near-zero duration, which is the right default but the wrong final state for every case. Some animations should vanish entirely; some must keep working but without movement; a few can simply run faster. Treating them all identically either leaves decorative motion technically running or, worse, strips a loading indicator that a user needs. The task is to pick, per animation, the fallback that removes vestibular risk while preserving whatever meaning the animation carried.

Root cause: why a blanket disable is not enough

A @keyframes timeline can be doing any of three jobs. It might be decorative — a hover bounce, a hero flourish — where the motion is the whole point and removing it costs nothing. It might be essential — a spinner, a progress fill, an item that animates to show where it was filed — where the motion communicates state a reduced-motion user still needs. Or it might be ambient — a slow looping gradient or drift that is neither meaningful nor abrupt.

The global reset in the prefers-reduced-motion cascade collapses all three to 0.01ms. For the decorative case that is fine; the element snaps to its end state and no one is worse off. For the essential case it is a regression: the “loading” signal disappears, or an element jumps without explaining where it came from. The fix is a restoration branch that re-expresses the essential motion as an opacity change or an instant, non-spatial state — because the harm to vestibular users is the visual displacement itself, not the fact that an animation exists. An opacity pulse at a steady position triggers no symptoms; a 300px slide does, no matter how briefly it runs.

Decision matrix: disable, shorten, or swap to opacity

Work down the table and stop at the first row that matches the animation you are handling.

Animation role Example Fallback pattern Why
Decorative, spatial Hero title rise, hover bounce Disable — let it snap to end state Motion carries no information; removing it costs nothing
Essential, spatial Toast slides in from edge Swap to opacity — fade at final position Preserves the “new item” signal without travel
Essential, rotational Loading spinner Swap to opacity — pulse in place Keeps the activity signal; drops the spin
Functional, tiny travel 4px focus lift, checkbox tick Shorten — sub-150ms, or swap to opacity Small, brief motion is low-risk; shortening removes perceived travel
Ambient loop Slow drifting background Disable — hold a static frame Continuous motion is distracting and never essential
Progress / determinate Progress bar fill Keep, but as width/opacity at rest The fill is the information; express it without bouncing

The dividing line is spatial displacement over a meaningful area. If the fallback still moves the element across the viewport, it has not solved the problem — regardless of how short you made it. Shortening is only a real fallback for motion that is already tiny.

Production pattern: all three fallbacks in one sheet

The block below shows the full-motion definitions gated to no-preference, then a reduce branch applying the correct fallback to each animation type. Read the comments — each marks which matrix row it implements.

/* ---- Full motion: only for users who have not opted out ---- */
@media (prefers-reduced-motion: no-preference) {
  .hero__title {                     /* decorative, spatial */
    animation: hero-rise 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
  }
  .toast--enter {                    /* essential, spatial */
    animation: toast-slide 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
  }
  .spinner {                         /* essential, rotational */
    animation: spin 800ms linear infinite;
  }

  @keyframes hero-rise {
    from { opacity: 0; transform: translateY(30px); }
    to   { opacity: 1; transform: translateY(0); }
  }
  @keyframes toast-slide {
    from { opacity: 0; transform: translateX(40px); }
    to   { opacity: 1; transform: translateX(0); }
  }
  @keyframes spin {
    to { transform: rotate(360deg); }
  }
}

/* ---- Reduced motion: apply the matched fallback per animation ---- */
@media (prefers-reduced-motion: reduce) {
  /* DISABLE — decorative snaps to its end state, no animation */
  .hero__title {
    animation: none;
    opacity: 1;
    transform: none;               /* clear any stale translate */
  }

  /* SWAP TO OPACITY — toast still announces itself, at rest */
  .toast--enter {
    animation: toast-fade 160ms ease both;
    transform: none;               /* no horizontal travel */
  }
  @keyframes toast-fade {
    from { opacity: 0; }
    to   { opacity: 1; }
  }

  /* SWAP TO OPACITY — spinner pulses in place instead of rotating */
  .spinner {
    animation: spinner-pulse 1.2s ease-in-out infinite;
    transform: none;
  }
  @keyframes spinner-pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.45; }
  }
}

Rendering Impact: transform + opacity — composite only. Every reduce branch clears transform and animates opacity alone, so no spatial travel reaches the compositor.

Verification checklist

Constraints and trade-offs

  • Clearing transform: none in the reduce branch is mandatory whenever the full animation used a translate or scale; skipping it leaves the element visibly offset at its keyframe start value.
  • Swap-to-opacity works only where the element’s meaning survives without position — a “moved to trash” animation that relied on travel to show where the item went may need a different affordance, such as a text confirmation.
  • Shortening is the weakest pattern and the easiest to misapply. Reserve it for motion already under a few pixels; never use it to “rescue” a parallax or a full-width slide.
  • Opacity pulses that dip too low or cycle too fast can themselves be distracting; keep the trough above roughly 0.4 and the period at or above one second.
  • These CSS fallbacks do not reach Web Animations API or JavaScript-driven motion; those must be gated in script, covered in detecting prefers-reduced-motion in JavaScript.

Frequently asked questions

Should I always disable keyframe animations for reduced motion? No. Disabling is correct only for decorative animations. Essential animations, such as a loading indicator or a transition that shows where content moved, should be swapped to an opacity-only or instant equivalent so the information they carry is preserved without spatial movement.

Is shortening a keyframe animation an acceptable fallback? Shortening is acceptable only when the motion is small in area and the shortened duration removes the perception of travel, such as a sub-200ms fade. Shortening a large translate or a parallax does not make it safe, because vestibular harm comes from the visual displacement, not the duration.