Honour prefers-reduced-motion: reduce by removing translation, scaling and parallax from scroll reveals while keeping content visible — handle it in CSS first, and in the observer only for effects CSS cannot switch off, listening for live changes to the preference.
Problem / Scenario Context
A company site has a polished scroll experience: sections slide up 40 px, images zoom from 90% to 100%, and a hero background moves at half the scroll speed. An accessibility audit flags it. People with vestibular disorders report that the motion causes nausea and dizziness, and the site ignores the operating-system setting that asks for less motion.
The team's first fix — skipping the observer entirely when the preference is set — leaves every section stuck at opacity: 0, because the hidden state still comes from the stylesheet. Reduced motion is not "no reveal"; it is "no movement". This page applies the idea to the reveal patterns in the Scroll-Triggered Reveal Animations topic.
Mechanics Explanation
prefers-reduced-motion is a media feature with two values, no-preference and reduce, set from the OS accessibility settings (Reduce Motion on macOS and iOS, "Show animations" off on Windows, "Remove animations" on Android). Browsers expose it to CSS through @media and to script through matchMedia.
Three properties of the feature shape the implementation:
- It can change while the page is open. A user can toggle the setting mid-session;
matchMedia(...).addEventListener('change', ...)reports it. - It is a request, not a switch. Guidance from WCAG 2.3.3 (Animation from Interactions) is to allow motion to be disabled unless essential. Fades are generally acceptable; large translations, zooms, parallax and spinning are the triggers.
- CSS applies it before any script runs. A media query in the stylesheet is honoured on first paint, whereas a script check happens later — so CSS should carry the primary behaviour.
Comparison Table: Where to Implement Each Piece
| Concern | CSS @media |
Script matchMedia |
Why |
|---|---|---|---|
| Remove transforms from the hidden state | yes | — | applies from first paint |
| Shorten or remove transitions | yes | — | no script needed |
| Disable scroll-driven animations | yes | — | animation: none |
| Skip JS-driven animation loops (canvas, rAF parallax) | — | yes | CSS cannot stop a rAF loop |
| Reveal everything immediately | optional | yes | avoids waiting for crossings at all |
| React to the setting changing mid-session | automatic | needs a change listener |
CSS re-evaluates on its own |
Minimal Reproducible Example
// The broken "fix": bail out, leaving CSS-hidden content hidden.
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
// do nothing
} else {
initReveal('.reveal');
}
.reveal:not(.is-revealed) { opacity: 0; transform: translateY(40px); }
Turn on Reduce Motion and reload: every section below the hero is invisible.
Production-Safe Solution
Put the motion decision in CSS, keep the observer running in both modes, and use script only for motion CSS cannot reach.
/* Default: slide + fade, gated by the opt-in class. */
.js-reveal .reveal:not(.is-revealed) { opacity: 0; transform: translateY(24px); }
.reveal { transition: opacity 420ms ease-out, transform 420ms ease-out; transition-delay: var(--stagger, 0ms); }
/* Reduced motion: fade only, shorter, no stagger. Content still reveals. */
@media (prefers-reduced-motion: reduce) {
.js-reveal .reveal:not(.is-revealed) { transform: none; }
.reveal { transition: opacity 150ms linear; transition-delay: 0ms; }
.parallax-layer { transform: none !important; }
.scroll-linked { animation: none; }
}
type Cleanup = () => void;
const reduceQuery = matchMedia('(prefers-reduced-motion: reduce)');
export function initMotionAwareEffects(): Cleanup {
let stopParallax: Cleanup | null = null;
const apply = (): void => {
// JS-driven motion is the only thing script must switch itself.
if (reduceQuery.matches) { stopParallax?.(); stopParallax = null; }
else if (!stopParallax) stopParallax = startParallaxLoop();
};
apply();
reduceQuery.addEventListener('change', apply); // live toggling
const stopReveal = initReveal('.reveal'); // runs in both modes
return () => { reduceQuery.removeEventListener('change', apply); stopParallax?.(); stopReveal(); };
}
declare function startParallaxLoop(): Cleanup;
declare function initReveal(selector: string): Cleanup;
The observer still decides when content appears in reduced-motion mode — useful for lazy work tied to the reveal — but nothing moves. For users who prefer content to be immediately present, an even simpler choice is to reveal everything at once when the preference is set, skipping the observer.
Testing the Preference
Every major browser's DevTools can emulate the media feature without changing OS settings: Chrome and Edge under Rendering → Emulate CSS media feature prefers-reduced-motion, Firefox through about:config (ui.prefersReducedMotion), and Safari's Develop menu. In automated tests, Playwright sets it per context:
import { test, expect } from '@playwright/test';
test.use({ reducedMotion: 'reduce' });
test('reveals do not translate under reduced motion', async ({ page }) => {
await page.goto('/features');
await page.mouse.wheel(0, 2000);
const t = await page.locator('.reveal').nth(5).evaluate((el) => getComputedStyle(el).transform);
expect(t === 'none' || t === 'matrix(1, 0, 0, 1, 0, 0)').toBe(true);
});
Pair this with a visual check that the revealed element's opacity reaches 1, so the test catches the "stuck hidden" regression as well as the motion one.
Beyond Reveals: Other Observer-Driven Motion
Reveals are the most visible case, but observers start several other kinds of motion, and each needs the same treatment.
Carousels that auto-advance when visible. An observer that starts a timer when the carousel enters the viewport is starting motion. Under reduced motion, do not auto-advance at all; show the controls and let the user move it.
Count-up numbers. Statistics that tick from zero when they scroll into view are motion and also a readability problem — the number is wrong until it finishes. Show the final value immediately under reduced motion.
Lottie and canvas animations. An observer that plays an animation on entry should show the poster frame (or the final frame) instead. These run in requestAnimationFrame loops that CSS cannot stop, so the matchMedia check is mandatory.
Smooth-scroll to the next section. "Scroll hijacking" that snaps between sections is motion the user did not initiate. It should be disabled entirely under reduced motion, and arguably always.
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
export function onVisiblePlay(el: HTMLElement, play: () => void, showFinal: () => void): () => void {
const io = new IntersectionObserver(([e], obs) => {
if (!e.isIntersecting) return;
reduce.matches ? showFinal() : play(); // same trigger, different payload
obs.disconnect();
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}
The observer stays identical in both modes; only the payload changes. That keeps the reduced-motion path exercised by the same code that runs by default, so it does not rot.
Verification Steps
- Emulate
reducein DevTools and scroll the page: content must appear, nothing may slide, scale or parallax. - Toggle the emulation mid-page and confirm JS-driven motion stops without a reload.
- Check the hero and first screen are fully visible on first paint in both modes.
- Run the Playwright test in CI to prevent regressions.
- Ask a user who relies on the setting to try the page — automated checks do not capture comfort.
Common Mistakes to Avoid
- Skipping the observer while CSS still hides content. Reduced motion must never mean reduced content.
- Checking the preference only once at startup. The setting can change during the session.
- Treating opacity fades as motion. Short fades are generally fine; removing them entirely is allowed but not required.
- Leaving
scroll-behavior: smoothon. Smooth scrolling on anchor jumps is itself motion; disable it under the same media query.
FAQ
Is a fade considered motion?
Generally no. Vestibular triggers are movement, scaling, rotation and parallax. A short opacity fade is widely considered acceptable under reduced motion, though removing it is also fine.
Should I show a toggle on the page as well?
It helps users who cannot or do not want to change OS settings, and it is required when motion is essential to a feature. Store the choice and apply it with a class that your reduced-motion CSS also matches.
Does reduced motion affect IntersectionObserver itself?
No. The observer is not an animation; it only reports visibility. What changes is what your CSS and callbacks do with that report.
What about auto-playing video that starts on scroll?
Treat it as motion. Under reduced motion, show the poster and a play button instead of starting playback when the video enters the viewport.
Related
- Fade In on Scroll with IntersectionObserver and CSS — the base reveal
- Accessible Observer-Driven Interfaces — broader accessibility patterns
- Autoplaying Video Only When in Viewport — motion that starts on visibility
↑ Back to Scroll-Triggered Reveal Animations