---
name: infinite-image-loop
description: Use when you want "Trendy, dynamic, casual" - Continuously scrolls or cycles image strips.
---

# Infinite Image Loop

> **Category:** Image / Media  -  **Personality:** Trendy, dynamic, casual
>
> **Best use:** Brand strips, galleries, social proof
>
> **Ratings:** Professional 3/5 - Casual 5/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 2/5 - Performance cost 3/5

## What it is

An infinite image loop (a "marquee") is a horizontal strip of images that scrolls forever with no visible start or end. The trick is to **duplicate the strip** and animate the track by exactly `-50%`: the moment the first copy has fully scrolled off, the second copy sits in precisely the same place, so the jump back to the start is invisible. It reads as effortless, always-on energy, which is why it's the go-to for logo/brand walls, "selected work" galleries and social-proof rows. Run two rails in opposite directions and it instantly feels alive.

## Dependencies / CDN

**None - pure CSS** for the motion (one `@keyframes` + `transform`). The demo adds a few lines of vanilla JS only to clone the strip and to honour reduced-motion. Fonts are 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=Hanken+Grotesk:wght@400;500;600;700&family=Instrument+Serif:ital@1&family=Unbounded:wght@500;600;700;800&display=swap" rel="stylesheet">
```

## HTML

Write each card **once**; the script appends an identical second copy so the loop is seamless. A second rail with `data-dir="right"` scrolls the other way.

```html
<div class="marquee">
  <div class="rail">
    <div class="track" data-dir="left">
      <figure class="card"><img src="/img/portrait-01.jpg" alt="Editorial portrait"><figcaption><b>Aria</b><span>Editorial</span></figcaption></figure>
      <figure class="card"><img src="/img/architecture-01.jpg" alt="Concrete architecture"><figcaption><b>Concrete Poems</b><span>Architecture</span></figcaption></figure>
      <!-- ...a few more cards... -->
    </div>
  </div>

  <div class="rail">
    <div class="track" data-dir="right">
      <!-- another set of cards -->
    </div>
  </div>
</div>
```

## CSS

```css
/* The clipped window + a soft fade on both edges */
.rail{
  overflow:hidden;
  -webkit-mask-image:linear-gradient(90deg,transparent,#000 7%,#000 93%,transparent);
          mask-image:linear-gradient(90deg,transparent,#000 7%,#000 93%,transparent);
}

/* The moving row: one flex line holding TWO identical copies */
.track{display:flex; width:max-content; will-change:transform}

/* Start the loop only when a parent .go class is present (set by JS, gated on motion) */
.go .track[data-dir="left"] { animation:scroll 40s linear infinite }
.go .track[data-dir="right"]{ animation:scroll 52s linear infinite reverse } /* opposite way */
.rail:hover .track,
.rail:focus-within .track{ animation-play-state:paused }  /* pause to inspect */

@keyframes scroll{ from{transform:translateX(0)} to{transform:translateX(-50%)} }

/* Each card carries its OWN trailing margin (not flex `gap`) so every stride
   is identical and -50% lands exactly on the start of the duplicate. */
.card{
  flex:0 0 auto; margin:0 16px 0 0;
  width:clamp(188px,23vw,238px); height:clamp(148px,19vw,182px);
  border-radius:15px; overflow:hidden;
}
.card img{display:block; width:100%; height:100%; object-fit:cover}

/* Static, tidy gallery for anyone who asked for less motion */
@media (prefers-reduced-motion:reduce){
  .track{ animation:none !important; transform:none !important }
}
```

## JavaScript

```js
(function(){
  var marquee = document.querySelector('.marquee');
  if(!marquee) return;

  // 1) Clone every card so each rail holds two identical copies back-to-back.
  //    Snapshot the children FIRST, or you'll clone the clones forever.
  marquee.querySelectorAll('.track').forEach(function(track){
    Array.prototype.slice.call(track.children).forEach(function(card){
      var clone = card.cloneNode(true);
      clone.setAttribute('aria-hidden','true');   // the copy is decorative
      track.appendChild(clone);
    });
  });

  // 2) Only start the loop if the visitor hasn't asked for reduced motion.
  var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
  if(!reduce) marquee.classList.add('go');
})();
```

## How it works

- **Two identical copies + `translateX(-50%)`.** Half the track width equals exactly one full copy, so when the animation reaches `-50%` the second copy occupies the first copy's original position. CSS `animation: ... infinite` snaps back to `0%`, and because that frame is pixel-identical, the seam is invisible.
- **Per-card margin, not flex `gap`.** A trailing `margin-right` on every card (including the last) makes each card's stride uniform; the gap between the two copies then matches the gap inside them, so `-50%` is perfectly aligned. A shared `gap` leaves the join half-a-gap short and produces a tiny visible jump.
- **`width:max-content`** lets the flex track grow to the full width of all cards, which is what makes the percentage-based `-50%` exact at any card size.
- **Opposite directions** come free: the second rail reuses the same keyframe with `animation-direction: reverse`. Different durations (40s vs 52s) stop the two rows from marching in lock-step.
- **Edge fade** is a `mask-image` gradient on the rail, so cards dissolve into the background instead of being hard-clipped.
- **Pause** is just `animation-play-state: paused` on `:hover` / `:focus-within` — the animation freezes in place and resumes seamlessly.

## Customization

- **Speed:** change the `animation` duration. Longer = slower/calmer; keep it `linear` so the scroll is constant (no ease in/out).
- **Direction:** drop `reverse`, or flip the keyframe to `translateX(-50%) -> 0`.
- **Density / size:** adjust the card `width`/`height` clamps and the trailing `margin`. The `-50%` math keeps working at any size as long as both copies stay identical.
- **Edge softness:** widen the `mask-image` stops (e.g. `12%`) for a longer fade, or remove the mask for hard edges.
- **One row or many:** add more `.rail`s; alternate `data-dir` for a woven look.
- **Speed-up/slow on hover:** instead of pausing, set a slower duration on `:hover` for a "lean in" feel.

## Accessibility & performance

- **Reduced motion is non-negotiable for auto-scroll.** JS skips the `.go` class and CSS forces `animation:none` under `prefers-reduced-motion: reduce`, leaving a clean static strip — no infinite movement for users who opted out.
- **Animate `transform` only** (never `left`/`margin`/`width`): it's GPU-composited and won't trigger layout. `will-change:transform` hints the compositor.
- **Pause on hover and focus** lets people actually look at an image, and `:focus-within` keeps it keyboard-friendly.
- The duplicated cards are marked `aria-hidden="true"` and the container carries an `aria-label`, so a screen reader hears the gallery described once, not twice.
- Keep total image weight sane (these are decorative); use `object-fit:cover` so mismatched aspect ratios still tile cleanly, and reasonably sized source files.

## Gotchas

- **The half-gap seam.** Using flex `gap` instead of a per-item margin makes the loop stutter at the join. Give each card its own trailing margin so every stride is equal.
- **You need exactly two copies.** `translateX(-50%)` assumes the track is precisely the duplicated set. Clone in JS (snapshot children first) or hand-duplicate — but don't clone the clones.
- **Both copies must be byte-identical** (same widths, same order). If one card is a different size in only one copy, the wrap drifts and the seam shows.
- **`mask-image` needs the `-webkit-` prefix** for Safari/iOS, and the gradient must use `transparent`/opaque `#000` stops (alpha mask), not coloured stops.
- **Don't fade the blur/animation duration** — animate transform; animating width or the mask is expensive and janky.
- A parent with `overflow:hidden` is required so the off-screen copy is clipped; without it the page gets a horizontal scrollbar.
