prefers-reduced-motion Recipes: A Reduction for Every Common Effect
You have inherited a marketing site with a parallax hero, a looping background video, a skeleton shimmer on every card, a staggered entrance on the feature list, and smooth-scrolling anchor links. All of it needs a reduced-motion path by Friday. This page is a catalogue of those paths — one recipe per effect, each with the CSS to paste and, more importantly, a note on what the reduced version should be rather than merely what it should not do. It sits under Accessibility in CSS Animations and applies the media-feature mechanics from Reducing Motion Preferences in CSS to concrete components.
Recipes in this page:
- A scoped global net that does not break
fill-mode - Parallax, autoplay video, and smooth scroll
- Skeleton shimmer and staggered list entrances
- Hover lifts that keep their feedback
Reduce, do not blindly remove
The crude approach — one universal selector block that zeroes every duration on the page — is a defensible safety net and a poor destination. A user who asked for less motion did not ask for an interface that stops responding. They asked you to stop moving things across their screen. A button can still acknowledge a press with an instant colour change. A panel can still cross-fade. A skeleton can still read as "loading". What has to stop is the travel: the parallax offset, the slide-in, the spin, the loop that never ends.
That reframing changes what each recipe below looks like. Instead of asking "which declarations do I delete", ask "what is this effect communicating, and what is the cheapest non-moving way to communicate the same thing". Parallax communicates depth and richness; a static, well-cropped image communicates the same thing with no displacement. A shimmer communicates "content is coming"; a steady tinted placeholder communicates it too. A stagger communicates reading order; a delay without movement preserves the order exactly. In each case there is a designed alternative, not an absence.
Complete working implementation
One page carrying all six recipes plus the net. Every block is independent — take the ones matching effects you actually ship.
<header class="hero">
<h1>Field notes</h1>
</header>
<figure class="promo">
<video class="promo__video" autoplay muted loop playsinline poster="/poster.jpg">
<source src="/loop.webm" type="video/webm">
</video>
<img class="promo__still" src="/poster.jpg" alt="A workshop bench with tools laid out">
</figure>
<ul class="features">
<li class="features__item" style="--i: 0">Offline first</li>
<li class="features__item" style="--i: 1">Versioned exports</li>
<li class="features__item" style="--i: 2">Audit trail</li>
</ul>
<div class="skeleton" aria-hidden="true"></div>
<button class="cta" type="button">Request access</button>
/* ---- Recipe 0: the net, scoped ------------------------------------
Scoped to a wrapper so it cannot fight your own component rules; use
it on third-party markup. 0.01ms rather than 0s so that a declared
animation-fill-mode: forwards still resolves to the final frame. */
@media (prefers-reduced-motion: reduce) {
.third-party *,
.third-party *::before,
.third-party *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ---- Recipe 1: parallax -> a plain, well-cropped image -------------
background-attachment: fixed is what produces the offset between the
image and the page as you scroll. Switching to scroll keeps the same
art direction with zero relative displacement. */
.hero {
min-height: 60vh;
background-image: url("/hero.jpg");
background-size: cover;
background-position: center;
background-attachment: fixed;
}
@media (prefers-reduced-motion: reduce) {
.hero { background-attachment: scroll; }
}
/* ---- Recipe 2: autoplay video -> the poster frame ------------------
CSS cannot pause playback, but it can choose which of two siblings is
in the box tree. display: none removes the video from layout, so the
decode stops mattering and the still takes its place. */
.promo__still { display: none; }
@media (prefers-reduced-motion: reduce) {
.promo__video { display: none; }
.promo__still { display: block; }
}
/* ---- Recipe 3: smooth scroll -> instant jump -----------------------
A long smooth scroll is unbroken full-field movement and is one of
the strongest triggers in the list. auto restores the instant jump,
which also lands focus predictably. */
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
/* ---- Recipe 4: skeleton shimmer -> a steady placeholder ------------
The shimmer is a moving gradient. Under reduce, drop the animation
and keep a flat tint so the box still reads as "not content yet". */
.skeleton {
height: 5rem;
border-radius: 0.5rem;
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
background: #e2e8f0; /* flat, still legible as a placeholder */
}
}
/* ---- Recipe 5: staggered list -> sequenced fade, no travel ---------
The stagger's job is to convey reading order. Keep the per-item
delay, drop the translate: order survives, movement does not. */
.features__item {
opacity: 0;
translate: 0 12px;
animation: item-in 400ms ease-out forwards;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes item-in {
to { opacity: 1; translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.features__item {
translate: none; /* nothing moves */
animation-name: item-fade; /* same delay, fade only */
}
@keyframes item-fade {
to { opacity: 1; }
}
}
/* ---- Recipe 6: hover lift -> keep the colour, drop the travel ------ */
.cta {
background-color: #2563eb;
color: #ffffff;
border: 0;
padding: 0.6rem 1.1rem;
border-radius: 0.375rem;
transition: background-color 200ms ease, translate 200ms ease;
}
.cta:hover,
.cta:focus-visible {
background-color: #1d49d8;
translate: 0 -2px;
}
@media (prefers-reduced-motion: reduce) {
.cta {
transition: background-color 120ms ease; /* feedback stays */
}
.cta:hover,
.cta:focus-visible {
translate: none; /* only the lift goes */
}
}
None of those six reduced paths is "nothing happens". The hero keeps its photograph, the promo keeps its image, anchor links still navigate, the skeleton still reads as pending, the list still arrives in order, and the button still confirms the hover. Only displacement was removed.
The technique that makes it work
Recipe 5 is the one worth studying, because it separates two things a stagger conflates. A staggered entrance carries two signals: sequence (these arrive one after another, in this order) and movement (each one travels upward into place). Only the second is motion. By overriding animation-name rather than animation, the shorthand's other components — the 400ms duration, the easing, the forwards fill, and critically the calc(var(--i) * 90ms) delay — all survive untouched. The list still resolves item by item at the original cadence; each item simply appears instead of arriving.
That is a general move: when an effect bundles several signals, override the narrowest property that carries only the motion. animation-name swaps the choreography while keeping the timing. translate: none removes displacement while keeping every other transform-adjacent property. Writing animation: none or transform: none instead is blunter and usually deletes something the user still wanted. Using the individual translate property rather than a transform: translateY() function matters here for the same reason — it can be neutralised on its own without disturbing a sibling rotate or scale.
Variation: a single duration token for the whole system
Six recipes is manageable; sixty is not. If your components already read timing from custom properties, one gate can dim all of them at once:
:root {
--motion-scale: 1; /* 1 = full speed */
--dur-fast: calc(120ms * var(--motion-scale));
--dur-base: calc(240ms * var(--motion-scale));
}
@media (prefers-reduced-motion: reduce) {
:root { --motion-scale: 0.01; } /* near-instant everywhere */
}
.panel { transition: opacity var(--dur-base) ease; }
This handles duration but not displacement — a translate: 0 -40px still travels the full 40 pixels, just faster, which is arguably worse. Pair the speed dial with a second token for distance (--travel: 12px, dropped to 0px in the same block) so both dimensions reduce together. Token-driven timing of this kind is covered further in fluid spacing tokens driving transition durations.
Browser support
prefers-reduced-motion is supported in Safari 10.1+, Firefox 63+, Chrome 74+ and Edge 79+. The individual translate property used in recipes 5 and 6 is newer — Firefox 72, Safari 14.1, Chrome 104 and Edge 104 — so if you support older Chromium, substitute transform: translateY() and neutralise it with transform: none. scroll-behavior: smooth is widely supported across current engines, and background-attachment: fixed is unreliable on iOS Safari regardless of preference, which is a good reason to treat parallax as an enhancement everywhere. Browsers with no support for the media feature ignore the @media blocks entirely and render the full-motion defaults, so no @supports guard applies.
FAQ
Should prefers-reduced-motion remove all animation?
Not always. Cut the motion that implies displacement while keeping feedback the user relies on, such as a colour change or a fade. Full removal is a defensible safety net, but a designed reduction is usually the better result.
How do I handle autoplaying media under reduced motion?
CSS cannot pause playback, so hide the moving element and reveal a still image that is already in the markup. Add a script check that calls pause() on the video for the cases where the element must stay in the layout.
Why should a reduced-motion duration be 0.01ms rather than 0s?
A truly zero duration means no animation is generated in some engines, so animation-fill-mode: forwards never applies and the element snaps back to its unanimated styles. A near-zero duration keeps the final frame while being imperceptible.
How do I reduce a staggered list without losing its order? Keep the per-item delay and drop the movement. Items still appear in sequence because the delay drives when each becomes visible, but nothing travels across the screen, so the ordering information survives without the motion.
Related
- Accessibility in CSS Animations — the parent guide on accessible motion.
- Reducing Motion Preferences in CSS — how the media feature is evaluated and where the block belongs in the cascade.
- Vestibular-Safe Animation Patterns — why these particular effects needed reducing.
- Staggered List Animations With Custom Properties — the full-motion version of recipe 5.
- Fluid Typography Without JavaScript — responsive scaling that needs no animated zoom.
Related articles
More pages in the same section.