---
title: "CSS Marquee: An Infinite Scrolling Ticker Done Right"
description: "A seamless CSS marquee: two copies and translateX(-50%), masked edge fades, speed in px/s, a real Pause button for WCAG 2.2.2, and reduced-motion support."
url: https://frontendhelpers.com/css-marquee-animation
updated: 2026-10-12
---

# CSS marquee: an infinite scrolling ticker done right

A loop with no visible seam, a hidden duplicate that screen readers skip, soft faded edges, a speed that survives content changes, and a Pause button that actually meets WCAG.

CSS recipe · By [AurumCodeWorks](https://frontendhelpers.com/about) · 7 min read · Updated 12 October 2026

-   Build 412 deployed to production
-   Lighthouse performance score: 98
-   Core Web Vitals: all three passing
-   New recipe: CSS marquee animation
-   Every tool is free, with no sign-up

**Text ticker** — two identical lists in one track, moved by `translateX(-50%)`; hover to pause.

-   [HTML & SEO](https://frontendhelpers.com/)
-   [CSS animations](https://frontendhelpers.com/css-animations)
-   [JavaScript tips](https://frontendhelpers.com/js-tips)
-   [Dev essentials](https://frontendhelpers.com/essentials)
-   [Frameworks](https://frontendhelpers.com/frameworks)
-   [Guides](https://frontendhelpers.com/guides)
-   [Performance](https://frontendhelpers.com/css-animation-performance)
-   [Accessibility](https://frontendhelpers.com/accessible-website-guide)
-   [About us](https://frontendhelpers.com/about)
-   [Changelog](https://frontendhelpers.com/changelog)

**Badge row with fades** — `mask-image` dissolves both edges; the copy’s links are hidden and unfocusable; speed set in px/s.

-   Free delivery on orders over £40
-   Workshop places for November now open
-   Opening hours: 9am to 6pm, Monday to Saturday
-   Gift cards available in store and online

**Accessible announcement bar** — a visible Pause button before the content, `aria-pressed` for its state; wraps statically under reduced motion.

## Why not the <marquee> element?

Browsers still render `<marquee>`, which is exactly why it keeps turning up in old templates. It should not go into anything new. The HTML Living Standard puts it in its list of [non-conforming features](https://html.spec.whatwg.org/multipage/obsolete.html), elements that are “entirely obsolete, and must not be used by authors”; the only reason the spec still describes it is so browsers keep old pages working. MDN’s [marquee reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/marquee) marks it deprecated, strongly discourages it, and points to CSS animation instead.

The practical problems matter more than the label: users cannot pause it, it ignores the reduced-motion setting, and it leaves a blank stretch before the text comes round again. A CSS marquee fixes all three.

## The seamless loop: two copies and −50%

Sliding one strip of content and starting again means a jump or an empty gap every cycle. The fix is to put the content in the track twice, side by side, and move the track left by exactly half of its own width. At that moment the second copy sits precisely where the first one started, so when the animation restarts at zero nothing visibly changes. The loop has no seam because there is nothing to see at the seam.

Three details make it work. The track is `width: max-content`, so it is as wide as both copies rather than squeezed to the container, and percentages in `translateX()` refer to the element’s own width, so −50% is always one copy. The timing function is `linear`, because any easing would make the strip slow down and speed up at every repeat. And the container clips the track with `overflow: hidden`; without it, a strip twice the width of the content pushes the whole page sideways.

```css
.mq {
  --mq-gap: 2rem;
  --mq-duration: 30s;
  overflow: hidden;
}
.mq-track {
  display: flex;
  width: max-content;
  animation: mq-scroll var(--mq-duration) linear infinite;
}
.mq .mq-group {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  gap: var(--mq-gap);
  padding-inline-end: var(--mq-gap);
  list-style: none;
}
.mq .mq-group > li { white-space: nowrap; color: var(--text); }
@keyframes mq-scroll {
  to { transform: translateX(-50%); }
}
```

The gap is where most marquees go wrong. If you put `gap: 2rem` on the track and let the items from both copies share it, a row of ten items has nine gaps, and half of the track is half a gap less than one copy. The result is a small jump at the end of every cycle. Here the gap lives inside each copy, and each copy also gets the same amount as `padding-inline-end`. Each copy is then items plus as many gaps as items, the two copies are identical, and −50% lands exactly on the start of the second.

One condition is easy to miss: a single copy must be at least as wide as the container. If it is narrower, the end of the second copy comes into view before the loop resets and you see an empty strip. For short content, repeat the items inside each copy until it is wider than the widest container you support.

## Hide the duplicate from assistive technology

The second copy exists only for the eye. A screen reader would otherwise read every item twice, and a keyboard user would tab through every link twice. Put `aria-hidden="true"` on the second list to remove it from the accessibility tree. That alone is not enough when the copy contains links or buttons, because `aria-hidden` does not stop an element receiving focus. A focusable element that announces nothing is worse than a duplicate, so give each link in the copy `tabindex="-1"`, as in the badge demo.

```html
<div class="mq mq-logos mq-fade" data-mq-speed="40">
  <div class="mq-track">
    <ul class="mq-group">
      <li><a class="mq-badge" href="/">HTML &amp; SEO</a></li>
      <li><a class="mq-badge" href="/css-animations">CSS animations</a></li>
      <!-- … -->
    </ul>
    <ul class="mq-group" aria-hidden="true">
      <li><a class="mq-badge" href="/" tabindex="-1">HTML &amp; SEO</a></li>
      <li><a class="mq-badge" href="/css-animations" tabindex="-1">CSS animations</a></li>
      <!-- … -->
    </ul>
  </div>
</div>
```

The `inert` attribute looks like a shortcut, but it also blocks mouse clicks, and the copy is on screen roughly half the time, so some badges would simply do nothing. With `tabindex="-1"` the copy stays clickable while keyboard and screen-reader users meet each link once.

## Fade the edges with a mask

Items sliced off by a hard edge look like a rendering bug; a fade reads as intentional. Older recipes used gradient overlays in the background colour, which break the moment the marquee sits on an image. A mask is independent of the background: `mask-image` hides parts of the element based on the alpha channel of the mask, and MDN’s [mask-image reference](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/mask-image) lists it as Baseline widely available, across browsers since December 2023. A gradient from transparent to black and back gives you soft edges over any surface.

```css
.mq-fade {
  mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
}
```

Only the alpha of the gradient matters, so `#000` could be any opaque colour. Put the mask on the clipping container, not on the moving track, so the fade stays fixed while the content passes through it.

## Set speed in pixels per second, not seconds

A fixed `animation-duration` is a hidden bug. The track travels one copy’s width per cycle, so when an editor adds three more items the strip suddenly runs faster, and a translation that is longer than the English original races past. Decide on a speed instead and derive the duration from it: duration = width of one copy ÷ speed. In our experience 30 to 60 pixels per second keeps text readable; badges and logos can go a little faster.

| Width of one copy | Desired speed | Resulting `animation-duration` |
| --- | --- | --- |
| 600px | 40 px/s | 600 ÷ 40 = 15s |
| 1,200px | 40 px/s | 1200 ÷ 40 = 30s |
| 1,200px | 80 px/s | 1200 ÷ 80 = 15s |
| 2,000px | 50 px/s | 2000 ÷ 50 = 40s |
| 3,000px | 120 px/s | 3000 ÷ 120 = 25s |

Because the duration is a custom property, you can set it per instance by hand, as the text ticker does with `--mq-duration: 40s`, or measure it. The other two demos carry a `data-mq-speed` attribute, and a few lines of script divide the measured width of one copy by it. Waiting for `document.fonts.ready` matters: measure before the web fonts arrive and you size the loop for the fallback font.

```js
document.fonts.ready.then(() => {
  document.querySelectorAll('.mq[data-mq-speed]').forEach((mq) => {
    const width = mq.querySelector('.mq-group').offsetWidth;
    mq.style.setProperty('--mq-duration', `${width / mq.dataset.mqSpeed}s`);
  });
});
```

## Pause on hover is not enough

Pausing on hover lets people stop the strip to read an item or aim at a link. `animation-play-state: paused` does it without restarting anything, because, as MDN’s [animation-play-state reference](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-play-state) notes, a resumed animation continues from where it was paused rather than from the beginning. Add `:focus-within` so the strip also stops while a keyboard user is on one of its links.

```css
.mq:hover .mq-track,
.mq:focus-within .mq-track,
.mq-paused .mq-track { animation-play-state: paused; }
```

It does not make the marquee accessible. WCAG Success Criterion 2.2.2, Pause, Stop, Hide, applies to moving, blinking or scrolling information that starts automatically, lasts more than five seconds and is presented in parallel with other content: for that, there must be a mechanism for the user to pause, stop or hide it, unless the movement is essential. An infinite marquee on a normal page meets all three conditions. The W3C’s [Understanding 2.2.2](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) document is explicit that content which pauses only while it has focus does not count as a mechanism, because it starts again as soon as focus moves on. Hover fails for the same reason, and does not exist at all on touch screens.

The fix is a real button, as in the third demo. Place it before the marquee in the source so keyboard and screen-reader users reach it first. Give it a constant label and expose its state with `aria-pressed`, rather than swapping the words between Pause and Play, so its name does not change under the user. The script toggles the attribute and a class on the element named by `aria-controls`; the CSS rule above does the rest. The same Understanding document also notes that every piece of content on a page must meet this criterion, which is why this page has a Pause all demos button above the examples.

```html
<button type="button" class="mq-toggle" aria-pressed="false" aria-controls="mq-news">Pause</button>
<div class="mq mq-news mq-fade" id="mq-news" data-mq-speed="50">
  <!-- track with two copies, as above -->
</div>
```

```js
document.querySelectorAll('.mq-toggle').forEach((btn) => {
  const target = document.getElementById(btn.getAttribute('aria-controls'));
  btn.addEventListener('click', () => {
    const paused = btn.getAttribute('aria-pressed') !== 'true';
    btn.setAttribute('aria-pressed', String(paused));
    target.classList.toggle('mq-paused', paused);
  });
});
```

## Reduced motion: stop and wrap

Continuous sideways movement is one of the patterns most likely to trouble people with vestibular disorders, and the `prefers-reduced-motion` media query reports when someone has asked their operating system for less of it. For a marquee, slowing down is not the answer. Stop the animation, drop the duplicate, remove the fades and let the items wrap onto as many lines as they need, so all the content is visible without anything moving. The Pause buttons go too, since there is nothing left to pause.

```css
@media (prefers-reduced-motion: reduce) {
  .mq-track {
    display: block;
    width: auto;
    animation: none;
  }
  .mq .mq-group {
    flex-wrap: wrap;
    justify-content: center;
    row-gap: 0.5rem;
    padding-inline-end: 0;
  }
  .mq .mq-group[aria-hidden="true"],
  .mq-toggle { display: none; }
  .mq-fade { mask-image: none; }
}
```

Wrapping suits announcement bars and badge rows. For a long logo wall where two or three lines would be too tall, the alternative is to keep a single row and set `overflow-x: auto` on the container so people scroll it themselves.

## Right-to-left pages

In a right-to-left document the flex row starts at the right edge, so the second copy sits to the left of the first and the track overflows to the left. Translating by −50% then pulls the content away from the start and opens an empty gap on the right. Reverse the movement with a mirrored keyframe. The `:dir()` pseudo-class matches the inherited direction even when `dir` is set on an ancestor, and it works in every current engine.

```css
@keyframes mq-scroll-rtl {
  to { transform: translateX(50%); }
}
.mq:dir(rtl) .mq-track { animation-name: mq-scroll-rtl; }
```

Because `padding-inline-end` is a logical property, the trailing gap follows the direction and the loop stays seamless. To run a left-to-right row the other way, `animation-direction: reverse` is enough.

## Keep it cheap to run

Animate `transform` and nothing else. A marquee built on `margin-left`, `left` or scroll position forces layout or paint on every frame, while a transform lets the browser paint the track once and have the compositor slide that layer, which keeps the strip smooth even while the main thread is busy. Our guide to [CSS animation performance](https://frontendhelpers.com/css-animation-performance) explains the pipeline behind that.

Use `will-change` sparingly, which here means not at all. A running transform animation already gets its own layer, so `will-change: transform` adds nothing during the animation and keeps the extra layer alive after it stops. Layers cost GPU memory in proportion to their size, and a marquee track is unusually wide: two copies of a 3,000px logo wall make a 6,000px layer. Several stacked rows on a phone add up quickly, so keep each copy only as long as the container requires.

If you need a slideshow of images rather than a continuous strip, the [CSS carousel snippet](https://frontendhelpers.com/css-image-animations#anim-css-carousel) in our [image animations library](https://frontendhelpers.com/css-image-animations) uses the same transform technique with steps between slides.

## Frequently asked questions

### Can I still use the HTML marquee element?

Browsers still render it, but the HTML standard lists marquee as entirely obsolete and says authors must not use it, and MDN marks it deprecated. It offers no pause control, no reduced-motion handling and no control over the loop. A CSS animation does all of that with a few lines.

### Why does my CSS marquee jump at the end of every loop?

The two halves of the track are not exactly the same width. With a flex gap between all the items there is one gap fewer than items, so translateX(-50%) stops half a gap short. Put the gap inside each copy and add the same amount as end padding, so each copy is exactly half the track.

### Does pausing on hover satisfy WCAG 2.2.2?

Not on its own. A marquee that starts automatically, runs for more than five seconds and sits beside other content needs a mechanism to pause, stop or hide it. Hover does not exist on touch screens, and the W3C explanation says pausing only while focus is on the content does not count. Add a visible Pause button.

### How do I keep the scrolling speed the same when the content changes?

Treat speed as pixels per second, not as a fixed duration. The track moves the width of one copy per cycle, so the duration is that width divided by the speed. Set it as a custom property, either by hand or with a few lines of JavaScript that measure one copy after the fonts load.

## Keep going

- [CSS image animations](https://frontendhelpers.com/css-image-animations): Carousels, Ken Burns zooms, reveals and hover effects, each with markup and CSS.
 
- [CSS animation performance](https://frontendhelpers.com/css-animation-performance): Which properties the compositor can animate, and how to check in DevTools.
 
- [Accessible website guide](https://frontendhelpers.com/accessible-website-guide): Keyboard access, screen readers, motion and contrast in practical steps.
