Pause Controls for Looping CSS Animations: Meeting WCAG 2.2.2

Looping motion is everywhere in modern interfaces: animated hero backgrounds, drifting gradients, pulsing "live" badges, auto-scrolling logo strips, rotating product showcases. Each one is small on its own; together they make pages that some readers cannot use. People with attention-related disabilities find persistent motion beside the text they are reading distracting to the point of preventing reading, and some motion triggers dizziness or nausea. WCAG 2.2.2 Pause, Stop, Hide requires a way to stop such motion. This page shows how to build that control in CSS, how to design loops that stop themselves, and how the control fits alongside prefers-reduced-motion. It belongs to Accessibility in CSS Animations in the CSS-Only Micro-Interactions & Animations guide.

What the criterion actually requires

Success criterion 2.2.2 applies to moving, blinking or scrolling information that meets three conditions: it starts automatically, it lasts more than five seconds, and it is presented in parallel with other content. For such content there must be a mechanism for the user to pause, stop or hide it — unless the movement is essential to the activity.

Does this animation need a pause control? Three yes-or-no questions in sequence: does it start automatically, does it last more than five seconds, is it shown alongside other content. If all three are yes and the motion is not essential, provide a pause, stop or hide mechanism. Any no means the criterion does not apply. Three questions decide it Starts on its own? no user action yes Runs over 5 seconds? including loops yes Beside other content? not the whole task yes Provide pause, stop or hide unless motion is essential Any "no": 2.2.2 does not apply still honour reduced motion An animation that stops itself within five seconds never needs a control. That is often the simplest way to comply.

Two consequences are worth stating plainly. First, the five-second threshold means the simplest compliant design is an animation that ends: three iterations of a two-second pulse, then stillness. Second, prefers-reduced-motion alone does not satisfy the criterion. It helps users who have set that operating-system preference, but 2.2.2 requires a mechanism available to everyone on the page itself. The media query and the control are complementary, not alternatives.

The complete implementation

The page below has a decorative animated background and a pulsing status badge. A single labelled checkbox pauses all of them. Users who prefer reduced motion get the paused state by default, and can still opt back in.

Live demoOne checkbox pausing every looping animation
Tick the box to freeze the drifting background and the pulsing badge; untick to resume from the same frame.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pause control for looping motion</title>
<style>
  body { font: 16px/1.6 system-ui, sans-serif; margin: 0; }

  .hero {
    position: relative;
    padding: 3rem 1.5rem;
    overflow: hidden;
    background: linear-gradient(120deg, #312e81, #1e3a8a, #0f766e);
    background-size: 300% 300%;
    color: #f8fafc;
    animation: drift 14s ease-in-out infinite alternate;
  }
  @keyframes drift {
    to { background-position: 100% 50%; }
  }

  .live {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
  }
  .live::before {
    content: "";
    width: 0.6rem;
    height: 0.6rem;
    border-radius: 50%;
    background: #f87171;
    animation: pulse 1.6s ease-in-out infinite;
  }
  @keyframes pulse {
    50% { opacity: 0.35; scale: 0.8; }
  }

  /* The control: an ordinary checkbox with a visible label. */
  .motion-toggle {
    position: absolute;
    top: 0.75rem;
    right: 0.75rem;
    display: inline-flex;
    gap: 0.4rem;
    align-items: center;
    padding: 0.3rem 0.6rem;
    border-radius: 6px;
    background: rgb(15 23 42 / 0.6);
    font-size: 0.85rem;
  }
  .motion-toggle input:focus-visible { outline: 2px solid #f8fafc; outline-offset: 2px; }

  /* Checked = paused. :has() lets the control live anywhere in the page. */
  :root:has(#pause-motion:checked) .hero,
  :root:has(#pause-motion:checked) .live::before {
    animation-play-state: paused;
  }

  /* Reduced-motion users start paused, so for them the same checkbox
     means the opposite: ticking it RESUMES the animation, and its
     visible label changes to say so. */
  @media (prefers-reduced-motion: reduce) {
    .hero, .live::before { animation-play-state: paused; }
    :root:has(#pause-motion:checked) .hero,
    :root:has(#pause-motion:checked) .live::before { animation-play-state: running; }
    .motion-toggle .label-default { display: none; }
  }
  @media (prefers-reduced-motion: no-preference) {
    .motion-toggle .label-reduced { display: none; }
  }
</style>
</head>
<body>
  <header class="hero">
    <label class="motion-toggle">
      <input type="checkbox" id="pause-motion">
      <span class="label-default">Pause animation</span>
      <span class="label-reduced">Play animation</span>
    </label>
    <h1>Release 4.2</h1>
    <p class="live">Live now</p>
  </header>
</body>
</html>

animation-play-state: paused freezes an animation at its current frame rather than resetting it, so pausing never causes a jump, and resuming continues from the same point. The control is a real <input type="checkbox">, so it is focusable, operable with Space, and announced as "Pause animation, checkbox, not checked". Its label text changes meaning for reduced-motion users, who start paused, so the same box reads "Play animation" for them.

The key technique: one control, many animations

A pause control is only useful if it stops everything that moves. The :root:has(#pause-motion:checked) pattern lets one checkbox, placed wherever the design wants it, reach every animated element on the page. Adding a new animation means adding its selector to the paused list — or, more robustly, giving every decorative animation a shared class.

A single switch for every loop A checkbox labelled pause animation feeds a root-level has selector. Lines fan out to a hero background, a live badge and a logo strip, each set to animation-play-state paused when the box is checked. :root:has(#pause-motion:checked) ☐ Pause animation one real checkbox hero gradient drift live badge pulse logo strip marquee animation-play-state: paused
/* Tag every decorative loop once... */
.loops { animation-play-state: var(--loops-state, running); }

/* ...and flip the shared state from the control. */
:root:has(#pause-motion:checked) { --loops-state: paused; }

The custom-property version is the one to adopt in a codebase: components opt in by adding the loops class, and the page-level control never needs to know which components exist. The same variable can also be set from a site-wide preference stored in local storage and applied by a small script, so a user's choice persists across pages — which the checkbox alone cannot do.

Where to put the control, and what it should say

A pause control that users cannot find does not meet the criterion in spirit. Three placement guidelines help.

Put it near the motion or at the top of the page. A control inside the animated region, as in the example, is discoverable because it sits where the eye already is. A site-wide control in the header — "Reduce motion" beside the theme toggle — is discoverable because it is in a predictable place and applies everywhere. Controls buried in a footer or a settings page arrive too late for someone already struggling to read.

Make it reachable early in the tab order. Keyboard users should meet the control before they reach the content the motion is distracting them from. For a hero animation, the control belongs at the start of the hero's DOM, not the end.

Label the action, not the state. "Pause animation" says exactly what ticking the box does. Labels such as "Motion" or an unlabelled icon leave users guessing whether the current state is on or off. If an icon is used for compactness, it still needs an accessible name, and the visible target must be large enough to hit comfortably.

Persisting the choice is worth the few lines of script it takes. A user who pauses motion on one page will expect the next page to respect that choice, and asking them to pause every page individually is a small but real barrier. Storing the preference and applying the paused state before first paint — the same technique used for theme preferences — makes the control feel like a setting rather than a per-page chore.

Designing loops that stop themselves

The most accessible looping animation is one that does not loop forever. Many decorative effects work just as well with a finite iteration count.

/* Pulse three times to draw attention, then rest. 3 × 1.6s = 4.8s,
   under the five-second threshold, so no control is required. */
.badge-new::before {
  animation: pulse 1.6s ease-in-out 3;
}

/* A marquee that scrolls once and stops at its start. */
.logos__track {
  animation: marquee 30s linear 1;
}

Where a loop truly needs to continue — a "recording" indicator, a live-stream badge — keep it small, subtle and slow, and still include it in the pause mechanism. Motion that is essential to the meaning, such as a progress indicator during a genuine wait, is exempt, but it should stop when the wait ends. The broader patterns for motion preferences are in prefers-reduced-motion Recipes, and the success criteria themselves are summarised in WCAG Motion Success Criteria.

Browser support

animation-play-state is supported in Chrome 43+, Edge 12+, Firefox 16+ and Safari 9+. :has(), used to let the control affect the whole page, is supported in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+; where it is missing, place the checkbox before the animated content and use the general sibling combinator (#pause-motion:checked ~ .hero) instead. prefers-reduced-motion is supported in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+.

FAQ

When does WCAG 2.2.2 require a pause control? When moving, blinking or scrolling content starts automatically, lasts more than five seconds, and is presented in parallel with other content. A decorative looping animation beside an article meets all three conditions, so it needs a way to pause, stop or hide it.

Is honouring prefers-reduced-motion enough to meet 2.2.2? No. The media query only helps users who have set the operating-system preference. 2.2.2 requires a mechanism on the page for everyone, including users who never found or changed that setting. Use both: stop motion by default for reduced-motion users and provide a visible pause control.

Can CSS alone provide a pause button? Yes. A checkbox with a clear label can toggle animation-play-state: paused on the animations through :has() or a sibling selector. The checkbox is a real form control, so it is keyboard operable and announced by screen readers.

Do loading spinners need a pause control? Generally not. 2.2.2 exempts motion that is part of an activity where it is essential, and a loading indicator that stops when loading finishes communicates progress. A spinner that keeps running indefinitely, or decorative motion beside the loader, should still be addressed.

Related articles

More pages in the same section.