Guide

How to detect and fix motion slop

Motion slop is generated animation that ships broken by default — no reduced-motion guard, infinite loops, off-budget effects, or calls to APIs that don't exist. (The full definition is in What is motion slop?.) The good news: you don't need a scanner, an account, or a CI plugin to find most of it. You need your operating system's reduced-motion toggle and about five grep commands. This is the triage I run on a codebase before I trust its animation layer.

Step 1 — The two-minute manual pass

Before touching the code, ask the interface directly. Turn on reduce motion at the OS level, then reload the page and watch:

  • macOS: System Settings → Accessibility → Display → Reduce motion.
  • Windows: Settings → Accessibility → Visual effects → Animation effects off.
  • iOS: Settings → Accessibility → Motion → Reduce Motion. Android: Settings → Accessibility → Remove animations.

Reload with the setting on. Anything that still moves and isn't essential to understanding the content is a finding. A loading spinner on a request that is genuinely in flight can stay; a decorative parallax, an auto-playing carousel, a pulsing "new" badge, or a hero that keeps drifting should have gone still. What you're checking is whether the page honors prefers-reduced-motion: reduce at all. If nothing changed between the two reloads, it almost certainly doesn't — which the next step confirms in seconds.

Step 2 — Grep the four defect classes

Motion slop clusters into four checkable defects. Each maps to a search you can run right now from the repo root. grep won't understand your code — it narrows the haystack so you can read the few lines that matter instead of all of them.

2a. Unguarded motion

First, find out whether the codebase guards motion anywhere:

grep -rn "prefers-reduced-motion" src

If that returns nothing, every animation and transition in the project is unguarded — you're done diagnosing this class, and the fix in Step 4 applies wholesale. If it returns hits, list every motion declaration and check which ones sit outside those guarded blocks:

grep -rniE "animation[-a-z]*[[:space:]]*:|transition[[:space:]]*:" src \
  --include=*.css --include=*.scss --include=*.less

You now have a candidate list with file and line numbers. For each hit, open the file and confirm it lives inside a @media (prefers-reduced-motion: no-preference) block (opt-in) or is neutralized inside a @media (prefers-reduced-motion: reduce) block. Anything outside both is a real finding. This is the one class where the OS pass and the grep pass corroborate each other — trust it.

2b. Infinite loops

Looping motion that never stops is the pattern WCAG 2.2.2 (Pause, Stop, Hide, Level A) exists to prevent: anything that moves automatically, runs longer than five seconds, and sits alongside other content needs a way to pause, stop, or hide it.

# CSS keyframe loops
grep -rn "infinite" src --include=*.css --include=*.scss

# JS animation loops (WAAPI, GSAP, Framer Motion, anime.js)
grep -rnE "iterations?:[[:space:]]*Infinity|repeat:[[:space:]]*(-1|Infinity)|loop:[[:space:]]*true" src

Each hit is a loop. It's a finding unless the moving element is both essential and shorter than five seconds, or it already ships a pause control. Spinners tied to a real pending state are usually fine; ambient decoration that loops forever is not.

2c. Off-budget effects

This is a performance problem, not a WCAG one — keep the two separate so you triage each correctly. Cheap animation stays on the compositor; expensive animation makes the browser redo work every frame. Surface the keyframes and read what they animate:

grep -rn "@keyframes" src --include=*.css --include=*.scss
grep -rniE "(width|height|top|left|right|bottom|margin|padding)[[:space:]]*:" src --include=*.css

A note on cost — and on being precise about it. Animating transform and opacity is cheap: the browser can hand those to the compositor and skip layout and paint. Animating layout properties (width, height, top, left, margin) forces the browser to recompute layout — a reflow — on every frame. Animating paint-only properties (box-shadow, background-color, filter) does not force layout, but it still repaints off the compositor and can be expensive over large areas. Only transform and opacity stay on the fast path. None of this is a WCAG success criterion — it's a Core Web Vitals concern that shows up as jank on mid-range phones.

2d. Hallucinated APIs and easings

Generated code invents animation methods, options, and easing names that look plausible and fail at runtime. This is the class grep is weakest at — you can't pattern-match "doesn't exist" — but you can surface the candidates and eyeball the unfamiliar ones:

# Custom/uncommon easings and timing functions to verify
grep -rnE "cubic-bezier|ease-[a-z-]+|steps\(" src --include=*.css

# JS easing/option strings passed to animation calls
grep -rnE "easing:[[:space:]]*['\"]|\.animate\(|\.to\(|\.from\(" src

Anything you don't recognize — ease-in-out-back, a spring config on a library that has no springs, an .animate() option that isn't in the spec — gets verified against that library's actual docs. When this class is dense, it's the signal that a tool or a run-time check earns its keep; the manual pass has diminishing returns here.

Step 3 — Separate findings from false positives

Not every hit is a defect. Before you file or fix, run each candidate through three questions:

  1. Is it already guarded? An animation inside a prefers-reduced-motion block is working as intended — skip it.
  2. Is it essential? Motion that conveys meaning the user would otherwise miss (a genuine progress indicator, a state transition that shows what changed) is allowed to run, even under reduce, if it's essential. Decoration is not.
  3. Does it already stop or pause? A loop under five seconds, or one with a visible pause/stop control, satisfies 2.2.2 regardless of the infinite keyword.

What's left after those three questions is your real list.

Step 4 — Fix each class

Unguarded motion: make the guard the default. Wrap non-essential animation so it only runs when the user hasn't asked for less — and, while you're there, give looping motion a finite end instead of letting it run forever:

/* before: unguarded, and loops forever for everyone */
.badge { animation: pulse 0.8s infinite; }

/* after: opt-in only, and it comes to rest */
@media (prefers-reduced-motion: no-preference) {
  .badge { animation: pulse 0.8s ease-in-out 3; } /* 3 runs, then still */
}

The "after" is finite on purpose: three iterations draw the eye, then the element settles. If the motion must loop (a spinner on a real pending request), keep the loop but gate it behind the same guard and remove it the moment the state resolves.

Infinite attention traps: anything that must keep moving longer than five seconds needs a pause path — a control, or logic that stops it once its job is done. Don't rely on the loop being "short enough"; make stopping explicit.

Off-budget effects: re-express the motion in transform and opacity. A menu that slides in via left: -320px → 0 becomes transform: translateX(-320px) → none; a card that grows via width becomes transform: scale(). Same visual, compositor path, no per-frame reflow.

Hallucinated APIs: there's no clever fix — replace the invented call or easing with a real one and run it. This is why the class matters: it's the one that looks fine in review and only fails when someone loads the page.

When to reach for a tool

This triage catches most motion slop in a few minutes and costs nothing, which is the point — the barrier to checking should be low enough that it actually happens. Its limits are honest ones: grep can't scope a declaration to its media query, can't tell essential motion from decorative, and can't know an easing name is fake. That's where automation helps. The free motion check does the CSS half of this pass statically against WCAG 2.2.2 and 2.3.3 — it resolves whether each animation is actually guarded, and shows every finding with its fix. No signup, nothing stored. For the full, tool-independent process with the WCAG criteria and levels spelled out, see How to verify web motion accessibility.

The structural answer, past detection, is to stop hoping generated code follows the rules and make them non-optional — the layer MotionSpec builds: animation assembled from verified primitives that ship the guard and the budget by construction, so the four defects above can't appear in the first place.

Check a real page in ten seconds.

Paste any URL into the free motion check and see what a static scan finds — every finding with the fix.

Run the free motion check