02 / blog / accessibilityNovember 20266 min read

Reduced motion for scroll animations, effect by effect

prefers-reduced-motion: reduce is a request for less motion, not for none. Turning every scroll effect off can hide content; leaving them all on ignores the reader. Each kind of effect has a right answer, and here they are, with the code and the tests.

  • Accessibility
  • prefers-reduced-motion
  • accessibility
  • Scroll effects
Media query
prefers-reduced-motion
WCAG
2.3.3 (AAA)
Default here
finished state
01The request

What the setting asks for

Operating systems let people ask for less motion, and browsers pass that on as the media query prefers-reduced-motion. For people with vestibular disorders, large movement tied to scrolling can bring on dizziness or nausea. WCAG 2.1 names it in success criterion 2.3.3, Animation from Interactions: motion that an interaction sets off can be turned off, unless it is essential.

reduce.css / reduce.js10 lines
/* CSS: the same media query the scripts read. */
@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
}

// JavaScript
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
if (reduce.matches) {
  // show the finished state now
}

The value is reduce, and that is the brief: take out motion that plays at the reader, and keep everything they came for. Below, effect by effect.

EffectUnder reduced motionWhy
A path drawing itself, a fade-up, split text, a counter, parallaxShow the finished state at onceDecoration played at the reader
A video scrubbed by the scrollShow the end frameThe frames move on their own
Snapping to the next sectionJump instead of glideIt moves the page itself
A horizontal strip moved by the scrollKeep it moving with the scrollFollows the reader’s own input; holding it hides the panels
A looping animation on a timerStop itMotion with no input at all
02Decoration

Reveals and drawings: show them finished

A path drawing itself, a card fading up, a headline arriving word by word, a counter running to its number, a parallax layer: all decoration, played at the reader. Under reduced motion, show the finished state the moment the page loads, and run whatever was waiting for the animation to end.

reveal.js9 lines
function reveal(el) {
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
    el.style.opacity = '1';
    el.style.transform = 'none';
    onRevealed(el);            // whatever waits for the reveal still runs
    return;
  }
  // ...otherwise animate from the hidden state as the element scrolls in
}
03scroll-jacking

Snapping: jump instead of glide

A snap that eases the page to the next section moves the whole viewport by itself. That is exactly the motion the setting is about. Keep the snap and drop the glide: go there in one step.

snap.js4 lines
function snapTo(top) {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  window.scrollTo({ top, behavior: reduce ? 'instant' : 'smooth' });
}
04Direct input

Scrubbing that follows the scroll can stay

Some effects move only while the reader scrolls, one pixel for one pixel: a horizontal strip of panels, a drawing scrubbed back and forth. That is closer to dragging something than to watching it, and freezing it has a cost. A horizontal strip shows its panels only through that movement; hold it still and every panel but the first is out of reach, for exactly the readers who asked for less motion.

So this is a judgement, not a rule: keep scrubbing that maps directly to the reader’s own scroll, and make it easy to opt out where the page has another way to the same content. A loop that plays on a timer is different. It moves with no input at all, and it should stop.

05Change

Read it live, not once

The setting can change while the page is open. Reading matchMedia once at start-up misses that until the next load. Listen for the change, and apply it to anything still to come.

live.js5 lines
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let reduced = reduce.matches;
reduce.addEventListener('change', (e) => {
  reduced = e.matches;     // a setting changed mid-visit applies now, not on reload
});

The two directions are not equal. Turned on, finish whatever is still moving, straight away: the reader may have turned it on because the page was making them unwell. Turned off, leave what is already shown alone and let motion come back on the next load; hiding content again so it can animate in is worse than one missing animation.

06Verify

Test it in a real browser

Playwright emulates the setting. Set it before the page loads, because most scripts read it once when they start, then check the finished state.

reduced-motion.spec.ts8 lines
import { test, expect } from '@playwright/test';

test('reduced motion shows the cards finished', async ({ page }) => {
  // Before the page loads: the scripts read the setting when they start.
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/');
  await expect(page.locator('.card').first()).toHaveCSS('opacity', '1');
});

In Chrome’s DevTools, the Rendering panel has the same switch (Emulate CSS media feature prefers-reduced-motion) for checking by eye.

07svg-scroll-draw

What svg-scroll-draw does

Every effect in the library follows the table above. Drawings, reveals, split text, counters and parallax show their finished state and still fire onComplete; a scrubbed video shows its end frame; scrollSnap jumps instead of animating; scrollHorizontal keeps scrubbing by default, with respectReducedMotion: true to hold it; a timeline keeps its scrubbing and stops its timed loop. Each behaviour is pinned by a test that emulates reduced motion: browser tests in Chromium, Firefox and WebKit for the reveals, text, counters, parallax, video, snapping, the horizontal strip and the timeline, and unit tests for the path drawing at start-up.

Since 2.22.0 every effect also follows the setting live (2.23.0 for the AOS, ScrollReveal and vivus drop-ins), the way the section above describes. Turned on mid-visit, each one finishes at once, exactly as a page loaded under reduced motion shows it, and fires onComplete once if it had not finished. Turned off again, what finished stays finished until the next load; snapping and the timeline’s loop, which hide nothing either way, follow it in both directions. Browser tests turn the setting on after the page has loaded, mid-drawing, mid-video and mid-story, in all three engines.

app.js8 lines
import { scrollReveal } from 'svg-scroll-draw/reveal';
import { scrollSnap } from 'svg-scroll-draw/snap';
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';

scrollReveal('.card');           // reduced motion: every card shown, no transition
scrollSnap('.section');          // reduced motion: jumps instead of gliding
scrollHorizontal('.track');      // keeps scrubbing; { respectReducedMotion: true } holds it
// Turned on mid-visit, each one finishes at once (2.22.0+).
→Parts list

Keep reading

More from the blog