slop audit
Blog post 49 / Accessibility and interaction

prefers-reduced-motion: CSS, JavaScript, and testing

Implement prefers-reduced-motion in CSS and JavaScript, preserve usable states, and test animation preferences before launching your website.

By Slop Audit · 7 min read

prefers-reduced-motion lets your website respond when a visitor requests less motion through their device settings. Use it to remove nonessential movement while preserving content, controls, and feedback. Before launch, test both preference values and confirm that menus, navigation, and loading states still work.

For a portfolio or public application page, the common failures are practical: an entrance animation leaves a heading invisible, a menu waits for a transition that no longer runs, or JavaScript keeps a background moving after CSS animations stop. This guide shows how to implement the preference and catch those failures on a deployed route.

prefers-reduced-motion launch checklist

  1. Inventory motion on initial load, hover, scrolling, and user interaction.
  2. Make the base HTML and CSS usable without animation.
  3. Enable optional CSS movement only when the preference allows it.
  4. Apply the same policy to JavaScript animation and explicit smooth scrolling.
  5. Keep loading, success, error, and focus states understandable without movement.
  6. Test a fresh load under each preference, then change it while the page is open.
  7. Repeat the important task on the deployed URL and record the result.

Use the accessibility checks before launch for the broader review. This article focuses on motion preferences rather than replacing keyboard, contrast, or screen-reader testing.

What reduce and no-preference mean

The media feature has two values: reduce means the user has indicated a preference to minimize motion; no-preference means no such preference was detected. It does not mean the user specifically requested elaborate animation. MDN's prefers-reduced-motion reference explains how the feature reflects device accessibility settings.

The query does not modify your page by itself. Your styles and scripts must respond to it. A CSS rule also cannot stop a video, canvas loop, or third-party animation simply because it matches the preference.

Create a small inventory before changing code. For each moving component, record its trigger, implementation, purpose, and static alternative. For example:

  • Hero entrance: CSS keyframes on load; show the final heading immediately.
  • Card hover: scale transform; keep the border or background feedback.
  • Menu opening: slide transition; open directly while keeping focus behavior.
  • Anchor navigation: smooth scrolling; move directly to the destination.
  • Background illustration: JavaScript loop; render a still frame.
  • Upload progress: changing status; preserve progress text even if a spinner stops.

That inventory identifies what the preference should change. It also helps reviewers distinguish decoration from information a visitor needs to complete a task.

Add reduced motion CSS without hiding content

Make the static state the default and enable optional movement inside no-preference. This example keeps the heading visible even when the animation never starts:

.hero-title {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: no-preference) {
  .hero-title {
    animation: hero-enter 220ms ease-out;
  }

  @keyframes hero-enter {
    from {
      opacity: 0;
      transform: translateY(0.75rem);
    }
    to {
      opacity: 1;
      transform: none;
    }
  }

  html {
    scroll-behavior: smooth;
  }
}

Avoid a base rule such as opacity: 0 that depends on an animation or observer callback to reveal important content. A reduced-motion branch should not leave a blank hero or remove the primary action.

For an existing component, a targeted override can be easier than rewriting every rule:

@media (prefers-reduced-motion: reduce) {
  .hero-title {
    animation: none;
    opacity: 1;
    transform: none;
  }

  .menu-panel {
    transition: none;
  }

  html {
    scroll-behavior: auto;
  }
}

Put the override where its cascade priority beats the component's motion styles. Check specificity, cascade layers, and CSS loaded after navigation if the rule matches but movement remains.

The menu still needs an open state that makes it visible. Removing a transition should change how that state appears, not whether it appears. Keep hover feedback and visible keyboard focus independent of movement: a stable color, border, or outline can communicate the interaction without scaling the control.

Avoid copying a universal reset that forces every animation to an extremely short duration. It can leave repeated effects or disrupt components that use animation events as application logic. Prefer a component contract that works with animation disabled. A menu's state and focus transfer should not depend solely on transitionend firing.

Use prefers-reduced-motion in JavaScript

Read the same preference before starting JavaScript movement. `window.matchMedia()` returns a MediaQueryList whose matches value tells you whether the query currently applies.

For a decorative illustration animated through the Web Animations API:

const motionPreference = window.matchMedia(
  "(prefers-reduced-motion: reduce)"
);
const illustration = document.querySelector(".hero-illustration");
let animation;

function applyMotionPreference() {
  animation?.cancel();
  animation = undefined;

  if (illustration && !motionPreference.matches) {
    animation = illustration.animate(
      [
        { transform: "translateY(0)" },
        { transform: "translateY(-8px)" }
      ],
      { duration: 1800, direction: "alternate", iterations: Infinity }
    );
  }
}

applyMotionPreference();
motionPreference.addEventListener("change", applyMotionPreference);

// Call this when the component is removed.
function disposeMotion() {
  motionPreference.removeEventListener("change", applyMotionPreference);
  animation?.cancel();
}

The illustration's underlying CSS must define an acceptable still frame. Canceling the animation returns control to those styles. MDN's Element.animate reference documents the animation object returned by this method.

Listen for changes because the preference may change while the page remains open. MDN's MediaQueryList change event documentation describes that notification. In a component framework, create and clean up the listener and animation in the component lifecycle; do not leave an old loop running after a route unmounts.

Also inspect explicit JavaScript scrolling. A CSS override does not replace a hard-coded behavior: "smooth" option:

function revealSection(target) {
  target.scrollIntoView({
    behavior: motionPreference.matches ? "instant" : "smooth",
    block: "start"
  });
}

MDN's scrollIntoView reference lists the supported behavior values. Keep focus handling separate: scrolling an element into view does not establish the correct keyboard destination. Follow the keyboard accessibility testing guide when the action changes focus or opens a dialog.

Keep status feedback and pause controls

Reduced movement must preserve meaning. Replace a decorative spinner with stable text such as “Uploading…” while keeping the actual progress and completion state connected to the request. An error must remain visible even if its shake animation disappears.

Review autoplay carousels, looping video, and independently animated embeds separately. If an embed cannot honor the preference, consider a static preview with an explicit play action. Test the resulting controls rather than assuming the parent page's query reaches the embed.

WCAG 2.2 Animation from Interactions is a Level AAA criterion about disabling motion triggered by interaction unless it is essential. Pause, Stop, Hide is a separate Level A criterion with conditions for automatically starting moving, blinking, or scrolling information and auto-updating content. Where those conditions apply, provide the required user control; a reduced-motion query alone is not a complete accessibility review.

Test prefers-reduced-motion with Playwright

Set the preference before navigation so initial animations and startup scripts see the intended value. Playwright's `page.emulateMedia()` supports both reduce and no-preference.

This example checks the hero contract above and a site's own menu behavior. Replace the URL, selectors, and accessible names with values from your application:

import { expect, test } from "@playwright/test";

for (const preference of ["reduce", "no-preference"] as const) {
  test(`hero and menu work with ${preference}`, async ({ page }) => {
    await page.emulateMedia({ reducedMotion: preference });
    await page.goto("https://preview.example.com/");

    const heading = page.locator(".hero-title");
    await expect(heading).toBeVisible();
    await expect(heading).toHaveCSS("opacity", "1");

    if (preference === "reduce") {
      await expect(heading).toHaveCSS("animation-name", "none");
      await expect(page.locator("html"))
        .toHaveCSS("scroll-behavior", "auto");
    }

    const menu = page.getByRole("button", { name: "Open menu" });
    await menu.click();
    await expect(menu).toHaveAttribute("aria-expanded", "true");
    await expect(page.getByRole("link", { name: "Documentation" }))
      .toBeVisible();
  });
}

The opacity assertion matters: an element can occupy a visible layout box while remaining transparent. The animation assertion proves only that this heading's CSS keyframes are disabled. It does not prove that every animation, video, or canvas on the page honors the preference.

Add a separate test that starts with no-preference, changes to reduce after load, and verifies your JavaScript animation stops. Assert a component-specific state or inspect the animation you own. Then exercise the task again. Avoid relying on a screenshot alone: it cannot show whether a background is still moving between frames.

Run the checks in your supported browser projects and at relevant viewport sizes. Keep these contracts in the website regression testing workflow so a new component or animation library cannot silently restore the old behavior.

Verify the deployed page with Slop Audit

Complete a manual pass with the operating system's reduced-motion setting enabled. Reload the deployed route, open menus, follow section links, trigger loading and error states, and change the setting while the page is open. Record the browser, route, preference, action, and result. Repeat with the setting disabled to verify the normal path too.

Run a Slop Audit on the public URL to collect complementary desktop and mobile browser evidence, automated accessibility findings, and sampled keyboard-focus evidence before sharing the release. Use those findings alongside your motion test results to assign specific fixes.

Slop Audit does not emulate reduced-motion preferences or verify animation cancellation, autoplay controls, or every interaction. Its bounded scan cannot certify WCAG conformance. Keep the motion checks above in your own browser suite and human review, then use the public audit to inspect the surrounding launch experience.