# Scrolling tickers and animations that can't be paused

Source: https://easeweb.dev/learn/moving-content
Topics: Time limits and motion > Moving content can be paused and nothing flashes (WCAG 2.2.2, 2.3.1)
The fix: Let the messages stay still, side by side and wrapping on small screens; or show one at a time, changed only by Previous and Next buttons; or keep the scroll and put a Pause button before it that stops the movement and shows every message. Pausing on hover can stay as an extra, but it is never the way to stop it.
Test with: keyboard, mouse, reduced motion, zoom, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html https://www.w3.org/WAI/WCAG22/Techniques/general/G186 https://www.w3.org/WAI/WCAG22/Techniques/general/G4 https://www.w3.org/WAI/WCAG22/Techniques/failures/F16 https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/marquee https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A linen shop has a thin announcement bar across the top of every page. Its three messages, free delivery over $50, an autumn sale and a new range of shirts, scroll sideways in one long line, round and round for as long as the page is open. Holding the mouse pointer over the bar pauses it, and it starts again the moment the pointer leaves. There is no button to stop it, and nothing for the keyboard to reach. Many shops show the same bar as a small slider instead: one message at a time, swapped every four seconds, with an arrow at each end. The arrows step through the messages, but the timer carries on, and again only the mouse pointer held over the bar stops it.

## Why it fails

Success criterion 2.2.2 Pause, Stop, Hide applies to moving, blinking or scrolling information that starts by itself, lasts more than five seconds, and sits alongside other content. All three hold here, so the page needs a way for the visitor to pause, stop or hide the bar. Pausing on hover is not that way: it needs a mouse, works only while the pointer stays put, and gives keyboard, touch and voice users nothing. Understanding 2.2.2 says the same of animation that stops only while it has focus, because it ties the visitor to the moving thing. Technique F16 names scrolling content with no mechanism to pause it as a failure. The slider's arrows don't count either: they change the message, but nothing stops the rotation.

## Who is affected

The bar sits at the top of every page, so the movement follows the visitor through the whole shop. On a phone, the bar is also where the eye lands first.

## What automation and AI miss

The audit finds every CSS animation that runs forever and flags it for a person to check, but it can't tell a ticker from a gently pulsing dot, or see a Pause button somewhere else on the page. Movement that a script drives frame by frame, as many carousel and ticker libraries do, never shows up as a CSS animation at all. axe reports the old `<marquee>` element, which still scrolls in every browser, but not the CSS that replaced it. AI fixes often add `animation-play-state: paused` on `:hover` and `:focus-within`, which looks like a pause but stops only while the pointer or focus stays on the bar, or turn the animation off under the reduced-motion setting and stop there, which helps only people who found that setting. A slider that swaps its messages on a timer has no animation running at all; the audit flags it only when it is marked `data-autoplay="true"`, as many themes mark their announcement bars, or is a Swiper with autoplay on.

## Before: a ticker that only a mouse can pause

```html
<div class="ticker">
  <div class="ticker-track">
    <ul>
      <li>Free delivery on orders over $50</li>
      <li>Autumn sale: up to 30% off until 31 October</li>
      <li>New: linen shirts in five colours</li>
    </ul>
    <!-- The same three messages again, so the loop has no gap. -->
    <ul aria-hidden="true">…</ul>
  </div>
</div>
```

```css
.ticker { overflow: hidden; }
.ticker-track { display: flex; width: max-content; animation: ticker 30s linear infinite; }
.ticker ul { display: flex; gap: 3rem; margin: 0; padding: 0 3rem 0 0; list-style: none; }
.ticker:hover .ticker-track { animation-play-state: paused; }
@keyframes ticker { to { transform: translateX(-50%); } }
```

The bar starts moving as the page loads and never ends. The `:hover` rule is the only pause, so it needs a mouse, and the bar moves on as soon as the pointer leaves.

## Before: a slider whose arrows don't stop it

```html
<div class="notice-bar">
  <button type="button" class="notice-prev">
    <svg aria-hidden="true" focusable="false"><use href="#icon-previous"></use></svg>
    <span class="visually-hidden">Previous announcement</span>
  </button>
  <ul class="notices">
    <li>Free delivery on orders over $50</li>
    <li hidden>Autumn sale: up to 30% off until 31 October</li>
    <li hidden>New: linen shirts in five colours</li>
  </ul>
  <button type="button" class="notice-next">
    <svg aria-hidden="true" focusable="false"><use href="#icon-next"></use></svg>
    <span class="visually-hidden">Next announcement</span>
  </button>
</div>
```

```js
// The slider: the arrows step through the messages, and so does a timer, without end.
const bar = document.querySelector('.notice-bar');
const items = [...bar.querySelectorAll('.notices li')];
let current = 0;
function show(step) {
  current = (current + step + items.length) % items.length;
  items.forEach((item, i) => (item.hidden = i !== current));
}
bar.querySelector('.notice-prev').addEventListener('click', () => show(-1));
bar.querySelector('.notice-next').addEventListener('click', () => show(1));
let timer = setInterval(() => show(1), 4000);
bar.addEventListener('mouseenter', () => clearInterval(timer));
bar.addEventListener('mouseleave', () => (timer = setInterval(() => show(1), 4000)));
```

The slider swaps its message every four seconds from the moment the page loads, and never stops. Its arrows change the message, but the timer carries on. The only pause is `mouseenter`, so again it needs a mouse, and the timer starts again as soon as the pointer leaves.

## After: the messages stay still

```html
<ul class="notices">
  <li>Free delivery on orders over $50</li>
  <li>Autumn sale: up to 30% off until 31 October</li>
  <li>New: linen shirts in five colours</li>
</ul>
```

```css
.notices {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.25rem 2rem;
  margin: 0;
  padding: 0.5rem 1rem;
  list-style: none;
}
```

Nothing moves, so 2.2.2 has nothing to ask for, and nobody has to find a control. The messages are written once, so there is no hidden copy to keep in step, and they wrap onto a second or third line on a narrow screen instead of running off the edge. On a phone the bar grows taller as a result, so keep it to the messages that matter.

## After: one message at a time, with Previous and Next buttons

```html
<div class="notice-bar">
  <button type="button" class="notice-prev">
    <svg aria-hidden="true" focusable="false"><use href="#icon-previous"></use></svg>
    <span class="visually-hidden">Previous announcement</span>
  </button>
  <ul class="notices" aria-live="polite">
    <li>Free delivery on orders over $50</li>
    <li hidden>Autumn sale: up to 30% off until 31 October</li>
    <li hidden>New: linen shirts in five colours</li>
  </ul>
  <button type="button" class="notice-next">
    <svg aria-hidden="true" focusable="false"><use href="#icon-next"></use></svg>
    <span class="visually-hidden">Next announcement</span>
  </button>
</div>
```

```css
.notice-bar { display: flex; align-items: center; gap: 0.5rem; }
.notices { flex: 1; margin: 0; padding: 0; list-style: none; text-align: center; }
```

```js
const items = [...document.querySelectorAll('.notices li')];
let current = 0;
// Only a button press changes the message; nothing rotates by itself.
function show(step) {
  current = (current + step + items.length) % items.length;
  items.forEach((item, i) => (item.hidden = i !== current));
}
document.querySelector('.notice-prev').addEventListener('click', () => show(-1));
document.querySelector('.notice-next').addEventListener('click', () => show(1));
```

This is the rotating slider's markup without its timer. Nothing moves until someone asks, so 2.2.2 has nothing to ask for, and the bar keeps to one line on any screen. The messages stay in the HTML, so they work in translation tools, and without the script the first one still shows. The polite live region reads each new message to screen reader users who press Next, without moving their focus. Most visitors only ever see the first message, so put the one that matters most there.

## After: a Pause button that stops it and shows every message

```html
<div class="ticker">
  <button type="button" class="ticker-button">
    <svg aria-hidden="true" focusable="false"><use href="#icon-pause"></use></svg>
    <span class="visually-hidden">Pause announcements</span>
  </button>
  <div class="ticker-window">
    <div class="ticker-track">
      <ul>
        <li>Free delivery on orders over $50</li>
        <li>Autumn sale: up to 30% off until 31 October</li>
        <li>New: linen shirts in five colours</li>
      </ul>
      <ul aria-hidden="true">…</ul>
    </div>
  </div>
</div>
```

```css
.ticker { display: flex; align-items: center; gap: 0.5rem; }
.ticker-window { flex: 1; min-width: 0; overflow: hidden; }
.ticker-track { display: flex; width: max-content; animation: ticker 30s linear infinite; }
.ticker ul { display: flex; gap: 3rem; margin: 0; padding: 0 3rem 0 0; list-style: none; }
/* Stopped: every message in place, wrapping like any other text. */
.ticker.is-stopped .ticker-track { width: auto; animation: none; }
.ticker.is-stopped ul { flex-wrap: wrap; gap: 0.25rem 2rem; padding: 0; }
.ticker.is-stopped ul[aria-hidden] { display: none; }
@keyframes ticker { to { transform: translateX(-50%); } }
```

```js
const ticker = document.querySelector('.ticker');
const button = ticker.querySelector('.ticker-button');
// The icon and the hidden name change together.
function stop(stopped) {
  ticker.classList.toggle('is-stopped', stopped);
  button.querySelector('use').setAttribute('href', stopped ? '#icon-play' : '#icon-pause');
  button.querySelector('.visually-hidden').textContent =
    stopped ? 'Play announcements' : 'Pause announcements';
}
button.addEventListener('click', () => stop(!ticker.classList.contains('is-stopped')));
// Still from the start for people who asked for less motion.
stop(matchMedia('(prefers-reduced-motion: reduce)').matches);
```

The button is a real `<button>` at the start of the bar, so the keyboard reaches it before the messages and the rest of the page, and it stays stopped until someone presses it again. Stopping does more than freeze the scroll: the bar shows every message in place, so nobody has to catch the right moment to read one. People who have asked their system for less motion get the still bar from the start. On a slider, the same button stops the timer instead, and Previous and Next keep working.

## Which option to use

All three pass 2.2.2. Still messages cost no button and no script, and everyone can read every message, but the design gives up its movement and the bar can take two or three lines on a phone. One message at a time keeps the bar to one line on any screen, at the cost of two buttons and a little script, and the later messages are seen only by people who press Next. The Pause button keeps the movement the design was built around, at the cost of a button, a little script and a bar that moves for everyone until they find it. Choose still messages when they fit, one at a time when space is tight, and the Pause button when the movement is part of the brand.

## Implementation decisions

Pausing on hover is a kindness to mouse users reading a message, and it can stay, but it is never the mechanism. The same goes for pausing while the bar has focus: Understanding 2.2.2 rules out a pause that lasts only as long as focus stays on the moving content.

Starting stopped under the reduced-motion setting, as the Pause button fix does, helps the people who set it, but it is no substitute for the button. Many people who are distracted by movement never find that setting, or use a computer they can't change, and 2.2.2 asks for a mechanism on the page. The script sets the starting state rather than a media query, so the button and the bar always agree, and pressing Play is the visitor's own request to see it move.

The Pause button is an icon button, so it needs what any icon button needs: a name in hidden text or `aria-label`, an icon with 3:1 contrast against the bar, a size of at least 24 by 24 pixels, a visible focus ring, and an icon and name that change together. Put it before the moving content, not after the last message, and keep the choice for the rest of the visit, for example in `sessionStorage`, so the bar does not start again on every page.

The copy of the messages that makes the loop seamless needs `aria-hidden="true"`, or screen readers read every message twice. If the messages are links, use `inert` instead, which also takes the copies out of the tab order; `aria-hidden` on focusable links leaves Tab stops that read as nothing.

Motion that stops by itself within five seconds needs no control. An entrance animation, or an animated GIF exported to loop only a few times, passes as long as it is over in five seconds. A ticker can't meet that and still show its messages, so it needs one of the fixes above.

If a slider keeps its timer, give the message list `aria-live="off"` while it turns and `"polite"` once it is stopped, or every change interrupts screen reader users.

A close button that hides the bar also passes, since 2.2.2 allows pause, stop or hide. It is a good extra, but on its own it leaves the people who most need the delivery and sale details with the choice of reading moving text or not reading it at all.

The same failure shows up wherever something moves by itself alongside the content. Each takes the same fixes, stay still or a stop button beside it:

- Logo strips and "marquee" rows of products that slide sideways without end.
- Animated GIF, WebP and Lottie illustrations that loop for more than five seconds.
- Background videos, which need their own Pause button even when muted.
- The old `<marquee>` element, which has no way to pause and should be replaced, not styled.

Auto-advancing carousels and content that updates by itself, such as a news feed or live prices, fall under the same criterion, with their own topics.

## Verify the fix

1. Load the page and wait five seconds without touching anything. List everything still moving, scrolling or blinking, including images, logo strips, videos and carousels.
2. Using only the keyboard, stop each one from a control beside it. Move focus away and the pointer off it, and confirm it stays stopped.
3. With it still, read every message in full, pressing Next where messages take turns, also at 200% zoom. Check that a Pause button's icon and name changed together.
4. Turn on Reduce motion in the operating system and reload. The bar starts still.
5. With a screen reader, read through the bar. Each message is read once, each button says what it does, and pressing Next reads the new message.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the bar, its messages or its library change.

## Limits

This is a starting pattern, not a guarantee for every application. It covers movement only: flashing, more than three times in any one second, is a separate check under 2.3.1, made with a flash analysis tool rather than by eye. Whether the movement is essential, and whether the page is calm enough once it is stopped, still needs a person to judge.
