Accessibility pattern · Interaction and motion

Reduced motion

Movement is added only when nobody has asked for less: the system's reduced motion setting decides by default, and a switch on the page can override it. Reduced does not mean nothing happens: slides become fades, the spinner pulses, and every change is still shown and announced.

WCAG criteria
6
Keyboard rules
4
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Motion

Following your system: full motion

Parallax hero

Full motion
Layers scroll at different speeds
Reduced
Scrolls as one picture

Monsoon in the Western Ghats A four-day walk from Lonavala

We left Lonavala before dawn, with the cloud still sitting in the valleys below the road.

By noon the rain had found us. The path turned into a stream, and the waterfalls across the valley doubled in size.

On the last morning the sky cleared over Rajmachi fort, and we could see the whole ridge we had walked.

Page transition

Full motion
Slides in from the side
Reduced
Fades in place

Step 1 of 3

Choose a plan

Pick monthly or yearly. You can change it at any time.

Loader

Full motion
Spins
Reduced
Fades in and out

3 photos ready to upload

Notification badge

Full motion
Bounces, and the bell swings
Reduced
Fades in

Smooth scroll

Full motion
Glides to the target
Reduced
Jumps there at once
Release notes, version 4.2

Jump to the summary

Search shows results as you type, and forgives small typing mistakes.

Calendar events can be dragged to a new day, or moved with Move to in their menu.

Every chart now has a data table, and focus rings are thicker.

Exports keep their column order, and the dark theme no longer flashes on load.

Summary

Search is faster, charts have tables, and dark mode loads cleanly.

Back to the top

No motion first, in CSS
/* Still by default: movement is added, never patched out. */
                            @media (prefers-reduced-motion: no-preference) {
                            .badge.is-new { animation: pop 400ms ease-out; }
                            }
                            /* Reduced: the same news, without the movement. */
                            @media (prefers-reduced-motion: reduce) {
                            .badge.is-new { animation: fade-in 200ms; }
                            }

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
Tab or ShiftTabMoves through the switch, each piece's buttons and links, and the two scrolling areas.
Space or EnterOn Reduce motion, turns reduced motion on or off for this demo, whatever the system says.
Arrow Down or Arrow UpIn the travel story or the release notes, scrolls; the hero's layers drift apart only in full motion.
EnterOn Jump to the summary, scrolls there, gliding or at once, and moves focus to the Summary heading.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Focus reaches the switchReduce motion, switch, off. Following your system: full motion
Space turns it onOn
Next shows the second stepStep 2 of 3
The upload starts, then endsUploading 3 photos… then 3 photos uploaded
A test message arrivesNew message received
Jump to the summary is followedSummary, heading level 5

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-reduced-motion" data-ap-reduced-motion>
  <div class="ap-reduced-motion__top">
    <div class="ap-reduced-motion__id">
      <h3 class="ap-reduced-motion__name">Motion</h3>
      <p class="ap-reduced-motion__source" id="reduced-motion-source" data-ap-source>Following your system: full motion</p>
    </div>
    <div class="ap-reduced-motion__control">
      <button type="button" class="ap-reduced-motion__switch" role="switch" aria-checked="false" aria-describedby="reduced-motion-source" data-ap-switch>
        <span class="ap-reduced-motion__switch-label">Reduce motion</span>
        <span class="ap-reduced-motion__track" aria-hidden="true"><span class="ap-reduced-motion__thumb"></span></span>
      </button>
      <button type="button" class="ap-btn ap-btn--ghost ap-reduced-motion__system" data-ap-use-system hidden>Use system setting</button>
    </div>
  </div>

  <div class="ap-reduced-motion__tiles">
    <div class="ap-reduced-motion__tile ap-reduced-motion__tile--wide" role="group" aria-labelledby="reduced-motion-hero-name">
      <div class="ap-reduced-motion__tile-top">
        <h4 class="ap-reduced-motion__tile-name" id="reduced-motion-hero-name">Parallax hero</h4>
        <dl class="ap-reduced-motion__modes">
        <div data-mode="full"><dt>Full motion</dt><dd>Layers scroll at different speeds</dd></div>
        <div data-mode="reduce"><dt>Reduced</dt><dd>Scrolls as one picture</dd></div>
      </dl>
      </div>
      <div class="ap-reduced-motion__scroller" role="region" aria-label="Travel story" tabindex="0" data-ap-parallax>
        <div class="ap-reduced-motion__hero" data-ap-hero>
          <svg class="ap-reduced-motion__layer ap-reduced-motion__layer--sun" viewBox="0 0 600 200" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false"><circle cx="452" cy="62" r="26"/></svg>
          <svg class="ap-reduced-motion__layer ap-reduced-motion__layer--far" viewBox="0 0 600 200" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false"><path d="M0 120 70 82l60 26 80-52 90 50 70-30 90 44 70-28 70 30v78H0Z"/></svg>
          <svg class="ap-reduced-motion__layer ap-reduced-motion__layer--mid" viewBox="0 0 600 200" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false"><path d="M0 150 90 104l70 34 90-58 100 62 80-36 100 50 70-20v64H0Z"/></svg>
          <svg class="ap-reduced-motion__layer ap-reduced-motion__layer--near" viewBox="0 0 600 200" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false"><path d="M0 172c60-26 120-30 190-12s140 20 210-6 140-20 200 4v42H0Z"/></svg>
          <p class="ap-reduced-motion__hero-card"><span class="ap-reduced-motion__hero-name">Monsoon in the Western Ghats</span> <span class="ap-reduced-motion__hero-sub">A four-day walk from Lonavala</span></p>
        </div>
        <div class="ap-reduced-motion__story">
          <p>We left Lonavala before dawn, with the cloud still sitting in the valleys below the road.</p>
          <p>By noon the rain had found us. The path turned into a stream, and the waterfalls across the valley doubled in size.</p>
          <p>On the last morning the sky cleared over Rajmachi fort, and we could see the whole ridge we had walked.</p>
        </div>
      </div>
    </div>

    <div class="ap-reduced-motion__tile" role="group" aria-labelledby="reduced-motion-steps-name">
      <div class="ap-reduced-motion__tile-top">
        <h4 class="ap-reduced-motion__tile-name" id="reduced-motion-steps-name">Page transition</h4>
        <dl class="ap-reduced-motion__modes">
        <div data-mode="full"><dt>Full motion</dt><dd>Slides in from the side</dd></div>
        <div data-mode="reduce"><dt>Reduced</dt><dd>Fades in place</dd></div>
      </dl>
      </div>
      <div class="ap-reduced-motion__steps">
        <p class="ap-reduced-motion__count" role="status" data-ap-step-count>Step 1 of 3</p>
        <div class="ap-reduced-motion__viewport">
          <div class="ap-reduced-motion__step" data-ap-step>
            <p class="ap-reduced-motion__step-name">Choose a plan</p>
            <p class="ap-reduced-motion__step-text">Pick monthly or yearly. You can change it at any time.</p>
          </div>
          <div class="ap-reduced-motion__step" data-ap-step hidden>
            <p class="ap-reduced-motion__step-name">Add your team</p>
            <p class="ap-reduced-motion__step-text">Invite people by email. They join when they accept.</p>
          </div>
          <div class="ap-reduced-motion__step" data-ap-step hidden>
            <p class="ap-reduced-motion__step-name">You are all set</p>
            <p class="ap-reduced-motion__step-text">Your workspace is ready. Start from a template or a blank page.</p>
          </div>
        </div>
        <div class="ap-reduced-motion__nav">
          <button type="button" class="ap-btn" data-ap-step-go="-1" aria-disabled="true">Back</button>
          <button type="button" class="ap-btn ap-btn--primary" data-ap-step-go="1" aria-disabled="false">Next</button>
        </div>
      </div>
    </div>

    <div class="ap-reduced-motion__tile" role="group" aria-labelledby="reduced-motion-load-name">
      <div class="ap-reduced-motion__tile-top">
        <h4 class="ap-reduced-motion__tile-name" id="reduced-motion-load-name">Loader</h4>
        <dl class="ap-reduced-motion__modes">
        <div data-mode="full"><dt>Full motion</dt><dd>Spins</dd></div>
        <div data-mode="reduce"><dt>Reduced</dt><dd>Fades in and out</dd></div>
      </dl>
      </div>
      <div class="ap-reduced-motion__upload" data-ap-upload>
        <div class="ap-reduced-motion__progress">
          <svg class="ap-reduced-motion__photos" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-9 8"/></svg>
          <span class="ap-reduced-motion__spinner" aria-hidden="true"></span>
          <svg class="ap-reduced-motion__done" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>
          <p class="ap-reduced-motion__upload-idle">3 photos ready to upload</p>
          <p class="ap-reduced-motion__upload-status" role="status" data-ap-upload-status></p>
        </div>
        <button type="button" class="ap-btn ap-btn--primary" data-ap-upload-go aria-disabled="false">
          <svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16V4M7 9l5-5 5 5M5 20h14"/></svg>
          Upload 3 photos
        </button>
      </div>
    </div>

    <div class="ap-reduced-motion__tile" role="group" aria-labelledby="reduced-motion-badge-name">
      <div class="ap-reduced-motion__tile-top">
        <h4 class="ap-reduced-motion__tile-name" id="reduced-motion-badge-name">Notification badge</h4>
        <dl class="ap-reduced-motion__modes">
        <div data-mode="full"><dt>Full motion</dt><dd>Bounces, and the bell swings</dd></div>
        <div data-mode="reduce"><dt>Reduced</dt><dd>Fades in</dd></div>
      </dl>
      </div>
      <div class="ap-reduced-motion__inbox">
        <button type="button" class="ap-reduced-motion__bell" data-ap-bell>
          <svg class="ap-reduced-motion__bell-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 9a6 6 0 0 1 12 0c0 6 2.5 7.5 2.5 7.5h-17S6 15 6 9"/><path d="M10 20a2.2 2.2 0 0 0 4 0"/></svg>
          <span class="ap-reduced-motion__sr">Notifications</span>
          <span class="ap-reduced-motion__badge" data-ap-badge><span data-ap-unread>2</span> <span class="ap-reduced-motion__sr">new</span></span>
        </button>
        <button type="button" class="ap-btn" data-ap-send>Send a test message</button>
        <p class="ap-reduced-motion__sr" role="status" data-ap-inbox-status></p>
      </div>
    </div>

    <div class="ap-reduced-motion__tile" role="group" aria-labelledby="reduced-motion-jump-name">
      <div class="ap-reduced-motion__tile-top">
        <h4 class="ap-reduced-motion__tile-name" id="reduced-motion-jump-name">Smooth scroll</h4>
        <dl class="ap-reduced-motion__modes">
        <div data-mode="full"><dt>Full motion</dt><dd>Glides to the target</dd></div>
        <div data-mode="reduce"><dt>Reduced</dt><dd>Jumps there at once</dd></div>
      </dl>
      </div>
      <div class="ap-reduced-motion__notes" role="region" aria-label="Release notes" tabindex="0" data-ap-jumps>
        <h5 class="ap-reduced-motion__notes-name" id="reduced-motion-notes-top" tabindex="-1">Release notes, version 4.2</h5>
        <p><a href="#reduced-motion-summary">Jump to the summary</a></p>
        <p>Search shows results as you type, and forgives small typing mistakes.</p>
        <p>Calendar events can be dragged to a new day, or moved with Move to in their menu.</p>
        <p>Every chart now has a data table, and focus rings are thicker.</p>
        <p>Exports keep their column order, and the dark theme no longer flashes on load.</p>
        <h5 class="ap-reduced-motion__notes-name" id="reduced-motion-summary" tabindex="-1">Summary</h5>
        <p>Search is faster, charts have tables, and dark mode loads cleanly.</p>
        <p><a href="#reduced-motion-notes-top">Back to the top</a></p>
      </div>
    </div>
  </div>

  <figure class="ap-reduced-motion__css">
    <figcaption>No motion first, in CSS</figcaption>
    <pre><code>/* Still by default: movement is added, never patched out. */
@media (prefers-reduced-motion: no-preference) {
<span class="ap-reduced-motion__in">.badge.is-new { animation: pop 400ms ease-out; }</span>
}
/* Reduced: the same news, without the movement. */
@media (prefers-reduced-motion: reduce) {
<span class="ap-reduced-motion__in">.badge.is-new { animation: fade-in 200ms; }</span>
}</code></pre>
  </figure>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 2.1.1 Keyboard Level A

    The switch, every piece's buttons and links, and both scrolling areas work from the keyboard; the scrolling areas take focus so the arrow keys scroll them.

  • 2.2.2 Pause, Stop, Hide Level A

    Nothing moves on its own for more than five seconds: the spinner stops when the upload ends, the badge bounces once, and the parallax moves only while someone scrolls.

  • 2.3.3 Animation from Interactions Level AAA

    Motion started by an interaction can be turned off: the system's reduced motion setting does it by default, and the switch does it on the page.

  • 2.4.3 Focus Order Level A

    The jump link moves focus to the Summary heading as well as scrolling, so the next Tab carries on from the summary.

  • 4.1.2 Name, Role, Value Level A

    The control is a button with role=switch, a visible name and aria-checked, described by the line that says where the setting comes from.

  • 4.1.3 Status Messages Level AA

    Upload progress, new messages and the current step are status messages, so no one has to see a movement to know something changed.

Usage

When to use it

Use it

  • Any interface with transitions, parallax, animated loaders or attention-grabbing badges.
  • Sites with large moving areas, such as scroll-linked heroes or full-page transitions, which most need a way to turn them off.

Use something else

  • Using reduced motion as a reason to drop feedback: keep every change visible, just without the travel.
  • Video and auto-playing media: those need their own pause control, which a motion setting does not replace.

Common failures

How it usually goes wrong

  • Removing the feedback with the motion

    A reduced mode that skips the loading state or the new badge leaves people guessing. Here movement turns into a fade, and every change is still shown and announced.

  • Motion first, patched out later

    Animating everything and adding a reduce query on top always misses something. No-motion-first starts still and adds movement under one condition.

  • Ignoring the system setting

    People set reduced motion once for every app. A site that ignores it makes them hunt for a separate switch, after the motion has already made them unwell.

  • No way to change it on the page

    On a shared or managed computer people cannot change system settings, and some only need it for one busy site. The switch here overrides the system both ways.

  • Smooth scrolling that ignores the setting

    scroll-behavior: smooth on the whole page, or scrollTo with behavior: smooth, glides every jump. Check the preference first; reduced jumps at once.

  • Parallax on by default

    Large layers moving at different speeds are a well-known trigger for vestibular disorders. Reduced mode scrolls the hero as one flat picture.

Notes

Building it

  • matchMedia("(prefers-reduced-motion: reduce)") gives the script the same answer as the CSS media query; listen for its change event so the page follows a new system setting without a reload.
  • Element.animate(), scrollTo() and scrollIntoView() do not read the CSS media query: check the preference before starting them, as this script does with one mode() function.
  • Reduced is not none: fades, color changes and instant swaps are generally safe; movement across the screen, zooming, spinning and parallax are what to avoid.
  • Store the choice the way you store a theme, in localStorage or a cookie, and apply it in the head before the first paint; this demo keeps it in memory, so Reset forgets it.
  • Without JavaScript nothing here moves at all: data-motion is never set, and only the two attribute values add movement or fades. That is the no-motion-first default.

Sources: WCAG 2.2 Understanding: Animation from Interactions · WCAG Technique C39: the CSS reduced motion query · Media Queries Level 5: prefers-reduced-motion

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction