---
name: infinite-marquee
description: Use when you want "Trendy, editorial, energetic" - Continuously scrolls text, logos, or images horizontally.
---

# Infinite Marquee

> **Category:** Motion / Interaction  -  **Personality:** Trendy, editorial, energetic
>
> **Best use:** Logo strips, announcements, portfolios
>
> **Ratings:** Professional 3/5 - Casual 4/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 2/5 - Performance cost 3/5

## What it is

A marquee is a strip of content that scrolls sideways forever with no visible start or end. You get the seamless loop by putting **two identical copies** of the content in one track and animating that track from `translateX(0)` to `translateX(-50%)` on a linear, infinite timeline — when copy A has scrolled exactly one copy-width off screen, copy B is sitting precisely where copy A began, so the reset is invisible. It is the classic editorial / brand device for announcement tickers, "as featured in" logo walls, and oversized rolling display type. This demo stacks all three: an inverted announcement ticker, two giant rows running in opposite directions on a skewed band, and a faded logo wall.

## Dependencies / CDN

None — pure CSS animation + vanilla JS. The demo only loads display fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Hanken+Grotesk:wght@400;500;600;700&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
```

## HTML

Each marquee is `viewport > track > two identical copies`. Mark the second copy `aria-hidden="true"` so assistive tech reads the content once. Direction is flipped with a `data-dir="right"` attribute; speed with a `--dur` custom property.

```html
<!-- announcement ticker -->
<div class="im-ticker">
  <div class="im-marquee im-fade" role="marquee" aria-label="Studio announcements">
    <div class="im-track" style="--dur:32s">
      <div class="im-set"><span class="im-tick">Vol. 07 — Out Now</span><span class="im-tick">Studio open for Q3 commissions</span><span class="im-tick">New editorial drop · Friday 09:00 GMT</span></div>
      <div class="im-set" aria-hidden="true"><span class="im-tick">Vol. 07 — Out Now</span><span class="im-tick">Studio open for Q3 commissions</span><span class="im-tick">New editorial drop · Friday 09:00 GMT</span></div>
    </div>
  </div>
</div>

<!-- big outline row (left) + ribbon row (right) -->
<div class="im-band" aria-hidden="true">
  <div class="im-row im-row-out im-marquee">
    <div class="im-track" style="--dur:30s">
      <span class="im-xl">ART DIRECTION<span class="im-mark">✻</span>EDITORIAL DESIGN<span class="im-mark">✻</span></span>
      <span class="im-xl">ART DIRECTION<span class="im-mark">✻</span>EDITORIAL DESIGN<span class="im-mark">✻</span></span>
    </div>
  </div>
  <div class="im-row im-ribbon im-marquee">
    <div class="im-track" data-dir="right" style="--dur:26s">
      <span class="im-xl">TYPOGRAPHY<span class="im-mark">✶</span>MOTION<span class="im-mark">✶</span></span>
      <span class="im-xl">TYPOGRAPHY<span class="im-mark">✶</span>MOTION<span class="im-mark">✶</span></span>
    </div>
  </div>
</div>

<!-- logo wall -->
<div class="im-marquee im-fade" aria-label="Press and stockists">
  <div class="im-track" style="--dur:40s">
    <div class="im-set"><span class="im-logo im-logo--serif">The Grayscale</span><span class="im-logo im-logo--grotesk">OBJEKT</span><span class="im-logo im-logo--anton">MÉTIER</span></div>
    <div class="im-set" aria-hidden="true"><span class="im-logo im-logo--serif">The Grayscale</span><span class="im-logo im-logo--grotesk">OBJEKT</span><span class="im-logo im-logo--anton">MÉTIER</span></div>
  </div>
</div>
```

## CSS

The whole effect is these ~12 lines; everything else is theming.

```css
/* THE ENGINE — duplicated track translated exactly one copy width */
.im-marquee{position:relative; display:flex; overflow:hidden}
.im-track{
  display:flex; flex:none; width:max-content; align-items:center;
  animation:im-left var(--dur,30s) linear infinite;   /* linear = constant speed */
  will-change:transform;
}
.im-track[data-dir="right"]{animation-name:im-right}   /* opposite direction */
@keyframes im-left {from{transform:translate3d(0,0,0)}     to{transform:translate3d(-50%,0,0)}}
@keyframes im-right{from{transform:translate3d(-50%,0,0)}  to{transform:translate3d(0,0,0)}}
.im-set,.im-xl{flex:none; white-space:nowrap}          /* never wrap a copy */

/* pause on hover, and globally when a parent gets .is-paused */
.im-marquee:hover .im-track{animation-play-state:paused}
.is-paused .im-track{animation-play-state:paused}

/* soft edges so content fades in/out instead of hard-cutting */
.im-fade{
  -webkit-mask-image:linear-gradient(90deg,transparent,#000 7%,#000 93%,transparent);
          mask-image:linear-gradient(90deg,transparent,#000 7%,#000 93%,transparent);
}

/* separators as margins (NOT literal spaces) keep spacing identical across the loop seam */
.im-tick::before{content:"✳"; margin:0 20px}
.im-logo::before{content:""; display:inline-block; width:1px; height:1.15em; background:#0002; margin:0 34px}
.im-mark{margin:0 .32em}

/* big outline display type + a diagonal, two-tone band */
.im-xl{font-family:'Anton',sans-serif; text-transform:uppercase; font-size:clamp(40px,9.2vw,104px); line-height:1}
.im-row-out .im-xl{color:transparent; -webkit-text-stroke:1.5px #181509}
.im-band{transform:rotate(-4deg); width:122%; margin-inline:-11%}   /* skew; clipped by the stage */

/* MUST: stop motion for users who ask for less */
@media (prefers-reduced-motion: reduce){ .im-track{animation:none !important} }
```

## JavaScript

Not required for the scroll itself. The demo adds three small enhancements: a pause/play button, a hard stop for reduced-motion users, and auto-pause when the strip is scrolled off-screen (CPU saver).

```js
(function () {
  var stage = document.getElementById('im-stage');
  if (!stage) return;
  var btn = stage.querySelector('.im-toggle');
  var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  var userPaused = false;

  function paint(p){ stage.classList.toggle('is-paused', p); if (btn) btn.setAttribute('aria-pressed', String(p)); }

  if (btn) btn.addEventListener('click', function(){ userPaused = !stage.classList.contains('is-paused'); paint(userPaused); });
  if (reduce) { userPaused = true; paint(true); }                 // start static

  if ('IntersectionObserver' in window) {                         // never override a user pause
    new IntersectionObserver(function(es){ es.forEach(function(e){ if (!userPaused) paint(!e.isIntersecting); }); },
      {threshold:0.01}).observe(stage);
  }
})();
```

## How it works

- **Two copies + `-50%`.** With exactly two identical copies in a `width:max-content` flex track, translating the track by `-50%` shifts it by one full copy. At the end of the keyframe the layout is pixel-for-pixel what it was at the start, so `infinite` loops with no jump.
- **`linear` timing** is essential — any ease would make the belt visibly accelerate and decelerate each cycle.
- **Opposite directions** are just a second keyframe that runs `-50% → 0`. Different `--dur` values per row create a lively parallax of speeds.
- **Separators live in margins / `::before`, not in literal spaces.** A trailing space at the end of a copy gets trimmed at the box edge, which makes the seam "hiccup"; a `::before` separator that belongs to each item keeps every gap — including the wrap point — identical.
- **Edge fade** is a `mask-image` gradient on the viewport, so items dissolve at the rim instead of popping.

## Customization

- **Speed:** change `--dur` (bigger = slower). Keep it proportional to content width so the px/sec rate feels consistent between rows.
- **Direction:** add/remove `data-dir="right"`.
- **Content:** swap text for `<img>` logos, cards, or portfolio thumbnails — the engine is content-agnostic; just keep both copies identical.
- **Density:** add more items per copy, or duplicate the copy 3×/4× and translate to `-33.33%` / `-25%` instead of `-50%` when one copy is narrower than the viewport.
- **Personality:** drop the skew + ribbon for a calm corporate logo strip; keep the outline display type for an editorial/streetwear feel.

## Accessibility & performance

- **Honour `prefers-reduced-motion`** — `animation:none` leaves a clean, fully readable static strip. The demo also flips the toggle to a paused state for these users in JS.
- **Give users a stop control.** Continuously moving content must be pausable (WCAG 2.2.2). Pause-on-hover plus the explicit button cover this.
- **Don't double-read.** Mark duplicated copies `aria-hidden="true"`; `role="marquee"` flags the region as non-critical, time-changing content.
- **Cheap on the GPU:** animate only `transform` (compositor-only), add `will-change:transform`, and pause off-screen instances with `IntersectionObserver`.

## Gotchas

- **Forgetting the second copy** (or making the copies even slightly different widths) breaks the seam — you'll see a gap or a jump every cycle.
- **One copy narrower than the container** leaves empty space before the loop resets; add more copies and reduce the translate percentage to match (`N` copies → `-100%/N`).
- **Easing instead of `linear`** makes the belt surge and stall — almost always wrong for a marquee.
- **`width:100vw` or negative-margin breakouts** trigger a horizontal scrollbar; contain the marquee in an `overflow:hidden` parent (here the rounded `.im-stage`) and size bands in `%`.
- **`-webkit-text-stroke`** is how the outline type is drawn; on the rare engine without it the text would be invisible, so ship a fallback: `@supports not (-webkit-text-stroke:1px #000){ .im-row-out .im-xl{color:#181509} }`.
- **`white-space:nowrap` is mandatory** on each copy, or narrow viewports will wrap the content and destroy the single-line belt.
</content>
</invoke>
