An accessible marquee that pauses on hover and stops for reduced motion
A seamless scrolling row with a real pause button, a duplicate set hidden from screen readers, and no animation at all under reduced motion.
Built 18 Aug 2026 · Last verified 18 Aug 2026
The short version
A marquee is a track holding two identical copies of its content, translated by exactly -50% on a linear infinite loop, so the copy arrives where the original started and the seam is invisible. Making it accessible is three separate things: the duplicate must be aria-hidden so nothing is announced twice, there must be a real pause control because WCAG requires one, and under reduced motion it should not animate at all.
How it works
- The track holds the item list twice. Translating it by -50% moves it exactly one copy's width, so at the end of the loop the second set sits precisely where the first began and the reset is invisible.
- That only holds if the two sets are identical down to the gap. A different padding on one of them, or an odd number of items handled specially, and the loop visibly jumps once per cycle.
- The second set is aria-hidden. It is a visual device, and without that a screen reader reads the whole list twice with no indication why.
- Pausing uses animation-play-state rather than removing the animation. Unsetting it restarts the loop from the beginning; play-state stops it where it stands and resumes from there.
- It pauses on :hover and on :focus-within. The second is the one usually missed: without it, tabbing to a link inside a moving row means chasing it.
- The pause button is the part that is actually required. WCAG 2.2.2 asks for a mechanism to stop any movement lasting more than five seconds, and hover is not a mechanism a keyboard or touch user has.
- Under reduced motion the animation is not slowed, it is removed, and the track becomes a wrapped static row with the duplicate set hidden. Everything stays readable and nothing moves.
The seam is the whole trick
A marquee that loops without a visible jump is one idea: put the content in twice, and translate the track by exactly half its width.
.track {
display: flex;
width: max-content;
animation: marquee 22s linear infinite;
}
@keyframes marquee {
to { transform: translateX(-50%); }
}At the end of the cycle the second copy sits precisely where the first started, so resetting to 0 changes nothing on screen. The loop is invisible because there is nothing to see.
It only holds if the two sets are genuinely identical, gap included. The usual way people break it is padding on one copy and not the other, or special-casing the last item's margin. Half a pixel of difference and the row twitches once every cycle, which is more noticeable than a marquee that obviously restarts.
width: max-content matters too. Without it the flex track is constrained by
its parent and -50% no longer corresponds to one copy's width.
Three separate accessibility problems
Marquees have a bad reputation, and it is earned. But the objections are three different problems with three different fixes, and they get run together.
The duplicate is announced. A screen reader has no idea the second copy is a visual device; it reads the list twice, back to back, with no explanation. One attribute fixes it:
<ul aria-hidden>{items}</ul>It moves while you are trying to read it. Hover pausing handles this for a
mouse, and :focus-within handles it for a keyboard. The second is the one
routinely missed, and without it tabbing to a link inside a marquee means
chasing a moving target.
.viewport:hover .track,
.viewport:focus-within .track {
animation-play-state: paused;
}Use animation-play-state, not animation: none. Unsetting the animation
restarts the loop from the beginning, so the row snaps back the moment your
cursor leaves. play-state stops it where it stands and resumes from there.
There is no way to stop it. This is the one that is not optional. WCAG 2.2.2 requires a mechanism to pause, stop or hide any movement that lasts more than five seconds and runs alongside other content. Hovering is not a mechanism: a keyboard user has no pointer, and a touch user has no hover state at all.
So the pause button is not a nicety, it is the requirement, and it is the part almost every marquee on the web is missing.
<button aria-pressed={!playing} onClick={() => setPlaying(p => !p)}>
{playing ? "Pause" : "Play"}
</button>aria-pressed rather than swapping an accessible name, so it is announced as a
toggle in a known state rather than as two different buttons.
Reduced motion is a cut, not a slowdown
The reflex is to slow the animation down. That is the wrong instinct here: a slower marquee is still a marquee, and for someone with a vestibular disorder the continuous horizontal drift is the problem regardless of speed.
So under prefers-reduced-motion this one does not animate at all. The track
stops being a track and becomes a wrapped, static row:
@media (prefers-reduced-motion: reduce) {
.track { animation: none; flex-wrap: wrap; width: 100%; }
.set[aria-hidden] { display: none; }
}Note the second rule. Once the animation is gone the duplicate has no purpose,
and leaving it in shows every item twice, which looks like a bug. The
[aria-hidden] attribute selector is a tidy way to target it: the copy that
exists for visual reasons is exactly the copy that is hidden from assistive
technology, so the attribute already identifies it.
The result is all of the content, none of the movement, and no separate markup path to maintain.
The mask, and the one prefix still needed
The ends are faded rather than cut, so items appear to travel into and out of view rather than being sliced by an invisible edge:
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);The -webkit- prefix is genuinely still required for Safari here, and unlike
backdrop-filter, this is one the build will not generate for you. It is also
removed under reduced motion, since a wrapped static row has no edges to fade.
When not to use one at all
Worth saying plainly: a marquee is a poor container for anything a visitor needs. Moving text is harder to read, harder to click, and impossible to scan. If the items are links people are meant to use, a wrapped static row is better in every measurable way and this experiment's reduced-motion branch is already that row.
Where it earns its place is decoration that happens to be made of words: a logo strip, a technology list, a ticker whose individual items do not matter much. That is what this demo is, and it is why the content is a stack list rather than navigation.
The source
MIT licensed. Use it in anything, no attribution needed.
Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.
Demo.tsxtsx
import { useState } from "react";
import styles from "./Demo.module.scss";
const ITEMS = [
"Next.js 16",
"React 19",
"TypeScript",
"SCSS Modules",
"Shopify",
"Playwright",
"Resend",
"Shiki",
];
export default function AccessibleMarquee() {
const [playing, setPlaying] = useState(true);
return (
<div className={styles.wrap}>
<div className={`${styles.viewport} ${playing ? "" : styles.paused}`}>
<div className={styles.track}>
<ul className={styles.set}>
{ITEMS.map((i) => (
<li key={i} className={styles.item}>
{i}
</li>
))}
</ul>
{/* The seam. An exact copy, hidden from assistive technology so the
list is not announced twice. */}
<ul className={styles.set} aria-hidden>
{ITEMS.map((i) => (
<li key={i} className={styles.item}>
{i}
</li>
))}
</ul>
</div>
</div>
{/* WCAG 2.2.2: anything that moves for more than five seconds needs a
mechanism to stop it. Hover and focus pausing are conveniences; this
button is the part that is actually required. */}
<button
type="button"
className={styles.toggle}
aria-pressed={!playing}
onClick={() => setPlaying((p) => !p)}
>
{playing ? "Pause" : "Play"}
</button>
</div>
);
}Demo.module.scssscss
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
width: 100%;
max-width: 100%;
min-width: 0;
padding: clamp(20px, 4vw, 34px) 0;
}
.viewport {
width: 100%;
/* Lets the box shrink below the track's max-content width instead of being
propped open by it. */
min-width: 0;
overflow: hidden;
/* Fades the ends so items enter and leave rather than being chopped off. */
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
.track {
display: flex;
width: max-content;
/* Translating exactly -50% lands the copy where the original started, which
is what makes the loop seamless. It only works because the two sets are
identical, gap included. */
animation: marquee 22s linear infinite;
}
@keyframes marquee {
to {
transform: translateX(-50%);
}
}
.set {
display: flex;
gap: 12px;
padding-inline: 6px;
list-style: none;
}
.item {
white-space: nowrap;
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.04em;
padding: 8px 14px;
border-radius: var(--r-pill);
border: 1px solid var(--line);
background: var(--glass);
color: var(--silver-200);
}
/* Pause on hover, on keyboard focus inside, and on the button. Using
animation-play-state rather than unsetting the animation means it resumes
from where it stopped instead of jumping back to the start. */
.viewport:hover .track,
.viewport:focus-within .track,
.paused .track {
animation-play-state: paused;
}
.toggle {
cursor: pointer;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 7px 16px;
border-radius: var(--r-pill);
border: 1px solid var(--line);
background: var(--glass);
color: var(--silver-200);
transition: 0.16s ease;
&:hover {
color: var(--silver-050);
border-color: var(--accent);
}
}
@media (prefers-reduced-motion: reduce) {
/* Not a slower marquee: no marquee. The track becomes a wrapped, static row
so every item is still readable, and the duplicate set is removed so
nothing is shown twice. */
.track {
animation: none;
flex-wrap: wrap;
justify-content: center;
width: 100%;
}
.set {
flex-wrap: wrap;
justify-content: center;
}
.set[aria-hidden] {
display: none;
}
.viewport {
mask-image: none;
-webkit-mask-image: none;
}
}Questions worth answering
Does it work in Safari?
Every current browser. The mask-image used to fade the ends still wants the -webkit- prefix in Safari, which the source below includes; everything else is plain keyframes.
Does it need JavaScript?
Only for the pause button's state. The scrolling, the hover pause and the focus pause are all CSS, so the marquee runs and stops correctly before any script loads.
Is it accessible?
Reduced motion. No animation whatsoever. The row wraps onto multiple lines as a static list and the duplicate set is removed, so all the content is readable and nothing is shown twice. A slower marquee would still be a marquee.
Keyboard. The track pauses on :focus-within, so tabbing into it stops the movement rather than making a moving target of it. The pause button is a real button carrying aria-pressed, which is what gives keyboard and touch visitors the control that hovering gives a mouse.
Want this kind of care on a build?
I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.