Meeting WCAG 2.2.2 Pause, Stop, Hide with Scroll-Driven Animations

SC 2.2.2 Pause, Stop, Hide was written for a web of marquees and auto-playing banners, and its central demand — a mechanism to pause moving content — maps onto scroll-driven animations in an unusual way: when animation progress is bound to scroll position via animation-timeline, the user’s scroll gesture is the mechanism. Stop scrolling and the motion stops, mid-keyframe, indefinitely. That makes most scroll-linked motion conformant by construction, but the exemption has precise boundaries, and the hybrid patterns that cross them are easy to miss. This article works through the classification, the marquee-style patterns that always need controls, and the pause-button implementation, as part of the WCAG 2.2 animation compliance criteria guide in the Accessibility & Inclusive Motion Standards section.

When to use this approach

SC 2.2.2 applies only when all three of its conditions hold: the content (a) starts moving automatically, (b) lasts more than five seconds, and © is presented in parallel with other content. Classify each animation against those conditions before writing any mitigation code:

  • Pure scroll-linked animation (animation-timeline: scroll() or view()) — fails condition (a): nothing moves until the user scrolls, and motion halts the instant scrolling halts. No pause control needed; verify the linkage is genuinely pure (Step 1 below).
  • Scroll-triggered, time-based animation — an animation started by scrolling but clocked by time (viewport-entry loops, autoplaying video revealed on scroll). Starting it with an interaction does not exempt it: once running unattended for more than five seconds beside other content, it needs a pause, stop, or hide mechanism.
  • Auto-advancing content (marquees, tickers, carousels) — meets all three conditions. Always needs an operable control, regardless of how the rest of the page uses scroll timelines.
  • Short one-shot effects — a 400 ms reveal or a same-document view transition finishes well inside five seconds; 2.2.2 does not apply.

The five-second clock measures total moving time, not iteration length: a 2-second loop with animation-iteration-count: infinite is unambiguously in scope.


SC 2.2.2 classification flow for scroll-driven animations A flowchart with three decisions. First: does the motion start automatically and last over five seconds beside other content? If no, SC 2.2.2 does not apply. If yes: is progress bound to scroll position through animation-timeline? If yes, scrolling is the pause mechanism and you verify the linkage is pure. If no: the animation runs on a time base, so an operable pause, stop, or hide control is required. Starts automatically, lasts > 5 s, shown beside other content? No SC 2.2.2 does not apply Yes Is progress bound to scroll position? animation-timeline: scroll() / view() Yes Scrolling is the pause mechanism — verify the linkage is pure (no time base) No Time-based motion: operable pause / stop / hide control required Verify with keyboard only, then automate

Implementation

Step 1: Verify the scroll linkage is pure

The 2.2.2 exemption for scroll-driven motion rests on scroll position being the only clock. Confirm each animation binds to a scroll or view timeline and carries no time-based residue:

.progress-fill {
  animation: fill-track linear;      /* no duration on a scroll timeline */
  animation-timeline: scroll(root);  /* the ONLY clock is scroll position */
}

@keyframes fill-track {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

Red flags that break purity: an animation-delay combined with a fallback duration, a second animation in the same shorthand running on the document timeline, or JavaScript that calls element.animate() on the same element when a scroll threshold fires. Any of these means the element keeps moving after the user stops scrolling — and condition (a) is back in play.

Step 2: Fix marquee-style auto-advance patterns

The classic 2.2.2 failure dressed in modern syntax is the auto-advancing pattern: a ticker translating on an infinite time-based loop, or a carousel advanced by setInterval. These meet all three trigger conditions the moment the page loads. Give each one a real control that toggles animation-play-state:

.ticker__track {
  animation: ticker-scroll 24s linear infinite;
}

.ticker[data-paused="true"] .ticker__track {
  animation-play-state: paused;
}

@keyframes ticker-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}
const ticker = document.querySelector('.ticker');
const button = ticker.querySelector('.ticker__pause');

button.addEventListener('click', () => {
  const paused = ticker.dataset.paused !== 'true';
  ticker.dataset.paused = String(paused);
  button.setAttribute('aria-pressed', String(paused));
  button.textContent = paused ? 'Play updates' : 'Pause updates';
});

Requirements the criterion imposes on the control itself: it must be keyboard-operable (a <button>, not a hover state), visible without pointer interaction, and it must expose its state (aria-pressed). “Hide” is an acceptable alternative to pause — a dismiss button that removes the ticker also conforms — but pausing on :hover alone does not, because touch and keyboard users have no equivalent gesture.

A stronger variant converts the marquee into scroll-linked motion outright: bind the track’s translation to a view() timeline so the ticker only advances as it moves through the viewport. That removes the auto-start condition entirely and eliminates the control requirement — the same reclassification trick used by scroll progress indicators, which read as continuous motion but are pure position mappings.

Step 3: Add a stop mechanism to hybrid scroll-triggered motion

Where scroll triggers time-based motion, you need a stop path. The cheapest conforming mechanism is often the one you already ship for other criteria: the prefers-reduced-motion override. But 2.2.2 requires a mechanism available to all users on the page itself — an OS preference does not satisfy it on its own. Combine both: an on-page toggle for 2.2.2, wired into the same custom property the media query uses, as in the motion intensity scaling approach:

// One site-wide switch stops every time-based ambient animation
function setAmbientMotion(enabled) {
  document.documentElement.dataset.ambientMotion = String(enabled);
  localStorage.setItem('ambient-motion', String(enabled));
}
[data-ambient-motion="false"] .ambient-loop {
  animation-play-state: paused;
}

/* The OS preference stops the same loops for users who set it */
@media (prefers-reduced-motion: reduce) {
  .ambient-loop {
    animation-play-state: paused;
  }
}

Persisting the choice in localStorage matters: 2.2.2 is assessed per page, and a control the user must re-find on every navigation is a conformance smell even if it technically passes.

Step 4: Handle view transitions and remaining edge patterns

Same-document view transitions are one-shot and typically finish in well under a second, so 2.2.2 rarely applies — unless you build a custom ::view-transition-group animation with a multi-second looping duration, which re-enters scope like any other time-based loop. Keep transition animations finite and short, and gate them behind the JavaScript guard for prefers-reduced-motion so reduced-motion users get an instant swap.

Verification

  1. Keyboard-only pass. Unplug the mouse. For every animation that starts automatically and can exceed five seconds, reach and operate its pause control with Tab and Enter alone. Confirm the control’s state change is announced (check aria-pressed in the accessibility tree).
  2. Stopwatch pass. Load each page and do nothing for ten seconds. Anything still moving at the ten-second mark that started without input is on the 2.2.2 list; cross-check each against your control inventory.
  3. Scroll-halt pass. Scroll each scroll-driven effect halfway, release, and watch for five seconds. Any continued movement means a time base leaked in — reclassify that element.
  4. Console check. With the page idle, run:
document.getAnimations()
  .filter(a => a.playState === 'running' && a.timeline instanceof DocumentTimeline)
  .map(a => a.effect?.target);

Scroll-linked animations report a ScrollTimeline; anything running on a DocumentTimeline while you are not interacting is auto-playing content that needs a control.

  1. Automated regression. In Playwright, assert the pause button works: click it, then poll getComputedStyle(track).animationPlayState for "paused", and assert aria-pressed="true".

Edge cases and gotchas

Scroll-snap-triggered auto-advance. Carousels that auto-advance after a scroll-snap settles are auto-playing content: the user’s gesture ended, the motion continued. The initiating interaction does not carry the exemption forward.

animation-play-state: paused versus removal. Pausing keeps the compositor layer and timeline attached, which is what you want for a resumable ticker. For “hide,” remove the element or set display: none — a visually hidden but still-animating element wastes compositor work and can still be announced by assistive technology.

Smooth-scroll and scroll-driven chaining. Programmatic smooth scrolling (scrollIntoView({ behavior: 'smooth' })) drives scroll timelines without the user’s hand on the wheel. A multi-second scripted scroll tour that plays scroll-linked animations is auto-playing motion in every sense that matters; provide a skip control.

Essential-motion claims. 2.2.2 exempts motion that is “part of an activity where it is essential” — a countdown, a loading spinner conveying indeterminate progress. Ambient parallax backdrops never qualify. Motion that is large or depth-simulating should additionally be reviewed against vestibular-safe motion design patterns, because passing 2.2.2 does not make an effect comfortable.

Five seconds total, not per iteration. Three sequential 2-second animations chained with animationend handlers are a single six-second moving experience under the criterion.

Browser-specific notes

Chrome / Edge (Chromium). animation-timeline ships in Chrome 115 and Edge 115. DevTools’ Animations drawer shows whether a running animation is attached to a ScrollTimeline or DocumentTimeline — the fastest manual purity check. animation-play-state toggling composites cleanly without restarting the keyframes.

Firefox. Scroll-driven animations are implemented behind a pref (enabled in Nightly), so shipping Firefox builds run only your time-based animations — which means your 2.2.2 obligations there reduce to the marquee-style patterns and their controls. Test pause controls in Firefox directly; they are plain CSS animations there and must work identically.

Safari. Scroll-driven animation support is in active development and available in Technology Preview builds; treat stable Safari like stable Firefox — time-based patterns only. Same-document view transitions work from Safari 18, so verify custom transition animations stay short there too.

All engines. prefers-reduced-motion is long-universal, so the paired media-query stop path in Step 3 works everywhere today, while the on-page toggle covers users who never set the OS preference.


Up: WCAG 2.2 Animation Compliance Criteria