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. Everyreducebranch clearstransformand animatesopacityalone, so no spatial travel reaches the compositor.
Verification checklist
Constraints and trade-offs
- Clearing
transform: nonein thereducebranch 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.
Related
- prefers-reduced-motion Architecture — the parent cascade these fallbacks slot into
- Detecting prefers-reduced-motion in JavaScript — gating imperative and WAAPI animations the cascade cannot reach
- Core CSS Animation Fundamentals — keyframe structure and the compositor-safe properties these fallbacks rely on