Guide

The infinite logo marquee that passes WCAG 2.2.2

The client-logo strip is the single most reliable way to put a Level A failure on a home page. The pattern is everywhere, the recipes are excellent CSS, and almost all of them end at animation: … infinite. That is precisely the shape WCAG 2.2.2 Pause, Stop, Hide is written about: motion that starts on its own, never ends, and sits in the middle of a page full of other content. This guide is the complete build — the seamless loop, the pause control that actually satisfies the criterion, a reduced-motion variant that does not freeze the row mid-scroll, and the screen-reader detail most versions skip.

What the standard recipe gets right, and where it stops

The canonical write-up is Smashing Magazine's infinite-scrolling logos in flat HTML and pure CSS, and it is genuinely good: the animation is animation: go linear var(--marquee-duration) var(--marquee-delay, 0s) infinite;, and it does handle the preference — "We can slow or eliminate the animation with the prefers-reduced-motion media query", implemented as animation-play-state: paused. On the control, it says:

"A more heavy-handed approach would be to add a button or some other control that toggles between play and paused states, but whether or not you go that route will depend on your project requirements."

Smashing Magazine, Infinite-Scrolling Logos In Flat HTML And Pure CSS

That is where the gap opens. The article does not mention WCAG 2.2.2 by name, and for an auto-starting strip that runs longer than five seconds beside other content, the button is not a project preference — it is the mechanism the Level A criterion asks for. The prefers-reduced-motion block is the other rail, WCAG 2.3.3, and it does nothing for the large majority of visitors who never opened that OS setting.

How common is this in the wild?

On 2026-08-29 we ran our static motion scan over 22 web-design agency home pages (method in the agency motion handoff). 18 of the 22 carried at least one infinite animation with no pause path, across 98 root-cause groups in total. Among those groups, 8 of the 22 sites had at least one whose keyframe or selector name pointed at a scroller, slider or marquee — names like marquee, dst-marquee-left and arrow-slider-scroll. Name-based identification is a heuristic, so read that as a floor, not a count.

The other thing the sample shows is that a strip is rarely alone. Sliders bring their own loaders: swiper-preloader-spin and splide-loading both appeared on multiple sites, and a utility framework adds its own — all four of Tailwind's built-in animate-* utilities are declared infinite. Fixing the marquee you wrote does not fix the spinner the carousel library shipped.

The markup

Two copies of the list, the second one hidden from assistive technology. A seamless loop needs the duplicate so the track can translate by exactly half its width and land where it started. Screen-reader users should hear the set once.

<section class="marquee" aria-labelledby="clients-heading">
  <h2 id="clients-heading" class="visually-hidden">Clients we work with</h2>

  <div class="marquee__track">
    <ul class="marquee__list">
      <li><img src="/logos/acme.svg" alt="Acme" width="120" height="40"></li>
      <li><img src="/logos/globex.svg" alt="Globex" width="120" height="40"></li>
      <!-- … -->
    </ul>

    <!-- Visual duplicate. Decorative only: hidden from assistive tech. -->
    <ul class="marquee__list marquee__list--dupe" aria-hidden="true">
      <li><img src="/logos/acme.svg" alt="" width="120" height="40"></li>
      <li><img src="/logos/globex.svg" alt="" width="120" height="40"></li>
      <!-- … -->
    </ul>
  </div>

  <button type="button" class="marquee__toggle" aria-pressed="false">
    Pause logo animation
  </button>
</section>

Note the duplicate's images carry alt="" as well as the container's aria-hidden — belt and braces, and harmless.

The CSS

.marquee {
  --marquee-duration: 40s;
  overflow: hidden;
}

.marquee__track {
  display: flex;
  width: max-content;
  animation: marquee-scroll var(--marquee-duration) linear infinite;
}

.marquee__list {
  display: flex;
  align-items: center;
  gap: 3rem;
  margin: 0;
  padding: 0 1.5rem 0 0;
  list-style: none;
}

/* Translating by exactly half the track lands on the duplicate's
   first item, so the loop has no visible seam. */
@keyframes marquee-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

Longer durations are calmer. Forty seconds for a full pass reads as drift rather than movement, and drift is far less likely to bother anyone.

Rail one: the pause control (WCAG 2.2.2)

Scope it to the root rather than the component, so the next widget that loops inherits the same switch:

html[data-motion-paused] .marquee__track {
  animation-play-state: paused;
}
const section = document.querySelector('.marquee');
const toggle = section.querySelector('.marquee__toggle');

toggle.addEventListener('click', () => {
  const paused = document.documentElement.toggleAttribute('data-motion-paused');
  toggle.setAttribute('aria-pressed', String(paused));
  toggle.textContent = paused ? 'Resume logo animation' : 'Pause logo animation';
});

Three properties decide whether this counts. The control must be visible — not revealed on hover. It must be keyboard-operable — a real <button> gets that for free, a <div> with a click handler does not. And it must actually stop the motion, not slow it. A hover-pause fails all three tests for a keyboard or touch user.

Rail two: the reduced-motion variant (WCAG 2.3.3)

Do not use animation-play-state: paused as the reduced state. It freezes the track wherever it happens to be, which usually means logos sliced in half at both edges of the container. The better reduced state is a static, wrapped row: no movement, nothing clipped, and every logo readable.

@media (prefers-reduced-motion: reduce) {
  .marquee { overflow: visible; }

  .marquee__track {
    animation: none;
    width: auto;
    flex-wrap: wrap;
    justify-content: center;
  }

  .marquee__list {
    flex-wrap: wrap;
    justify-content: center;
    gap: 2rem;
  }

  /* Safe to drop: this copy is the decorative clone, aria-hidden already.
     The real list stays in the DOM and visible. */
  .marquee__list--dupe { display: none; }

  /* The strip can no longer move, so the control has nothing to do. */
  .marquee__toggle { display: none; }
}

One rule in there deserves a note, because it looks like it breaks a principle we state elsewhere: never use display: none as a reduced-motion state. That rule is about content. Here the hidden element is the decorative duplicate, already marked aria-hidden="true"; the real list is untouched and fully visible. Hiding the actual logos under reduced motion would be the failure.

What this does not cover

  • Whether your strip was in scope at all. 2.2.2 needs all three conditions — automatic start, over five seconds, alongside other content. A strip that makes one pass and stops is outside it. So is one the user starts.
  • JavaScript marquees. If the strip is driven by a slider library, GSAP or a requestAnimationFrame loop, none of the CSS above reaches it and a CSS scan cannot see it. Guard it in JavaScript and give the control something to talk to.
  • The rest of the library you imported for it. Slider stylesheets ship loader animations of their own. Import only what you use, or guard the vendor layer explicitly.
  • Whether the control qualifies. A scan can prove no pause path exists. Whether the button you added is findable and genuinely operable is a human review — see how to verify web motion accessibility.
Scope note: detection, not conformance.

The free motion check reads a page's linked CSS and inline style blocks and reports the failing patterns it can see, each with a fix. It does not certify a page and it is not a legal determination. If a marquee is the only motion on the page, one scan settles it in ten seconds.

FAQ

Does an infinite logo scroller fail WCAG 2.2.2?

It fails when all three conditions hold: it starts automatically, it runs longer than five seconds, and it sits alongside other content. A logo strip in the middle of a home page meets all three, so without a pause, stop or hide mechanism it is a Level A failure. A strip that scrolls once and stops, or one the user starts, is outside the criterion.

Is a hover-pause enough for a marquee?

No. A hover-pause stops the movement only while the pointer is over the element and resumes the moment it leaves, and a keyboard-only or touch user never triggers it at all. WCAG 2.2.2 asks for a mechanism the user can operate — a visible, keyboard-operable control that actually halts the motion.

Does prefers-reduced-motion cover the marquee?

It covers one rail. Honouring the preference addresses WCAG 2.3.3 (Level AAA) for users who have set it. WCAG 2.2.2 (Level A) still asks for an on-page control for everyone who has not. A marquee needs both — and pausing under reduced motion is also the wrong reduced state, because it freezes the row mid-scroll with logos clipped at the edges.

How do you stop a screen reader reading the duplicated logos twice?

A seamless CSS marquee needs the item list duplicated so the translation can loop without a visible gap. Mark the duplicate aria-hidden="true" so assistive technology reads the set once. The visible copy stays in the accessibility tree; only the decorative clone is hidden.

Check your strip in ten seconds.

Paste the page URL into the free motion check — a static scan against WCAG 2.2.2 and 2.3.3 that reports every infinite loop without a pause path, with the fix. No signup, nothing stored.

Run the free motion check

One page tells you. A dated write‑up is what you hand to a client or a boss: the $29 Motion Report cites every candidate to WCAG 2.2.2 / 2.3.3 and ranks your score against 196 AI‑built pages — read a real one first. Fourteen‑day refund, no questions asked.

The full pre-launch list: the agency motion handoff →

MotionSpec checks and compiles UI animation for AI-generated web apps — it does not generate AI video.