Infinite Marquee

Two card rails scroll endlessly in opposite directions on a subtly tilted 3D plane. Each track is duplicated so the loop is seamless; hovering pauses the belt, and gradient masks fade both edges into the page. Motion halts entirely under reduced-motion.

  • @keyframes translateX loop
  • duplicated track
  • mask-image edge fade
  • perspective tilt

Source

A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.

galleries/infinite-marquee.astro
---
export const meta = {
  title: "Infinite Marquee",
  tags: ["gallery", "marquee", "scroll", "css-only"],
  description:
    "Two card rails scroll endlessly in opposite directions on a subtly tilted 3D plane. Each track is duplicated so the loop is seamless; hovering pauses the belt, and gradient masks fade both edges into the page. Motion halts entirely under reduced-motion.",
  tech: ["@keyframes translateX loop", "duplicated track", "mask-image edge fade", "perspective tilt"],
  height: 440,
};

const rowA = [
  { label: "Aurora", hue: "var(--spectrum-1, #8b5cf6)", h2: "var(--spectrum-2, #5b8def)" },
  { label: "Meridian", hue: "var(--spectrum-2, #5b8def)", h2: "var(--spectrum-3, #45d3e8)" },
  { label: "Tidewater", hue: "var(--spectrum-3, #45d3e8)", h2: "var(--spectrum-4, #3ddc97)" },
  { label: "Verdigris", hue: "var(--spectrum-4, #3ddc97)", h2: "var(--spectrum-5, #fbbf24)" },
  { label: "Solstice", hue: "var(--spectrum-5, #fbbf24)", h2: "var(--spectrum-6, #f472b6)" },
];
const rowB = [
  { label: "Ember", hue: "var(--spectrum-6, #f472b6)", h2: "var(--spectrum-1, #8b5cf6)" },
  { label: "Cobalt", hue: "var(--spectrum-2, #5b8def)", h2: "var(--spectrum-1, #8b5cf6)" },
  { label: "Prism", hue: "var(--spectrum-3, #45d3e8)", h2: "var(--spectrum-6, #f472b6)" },
  { label: "Moss", hue: "var(--spectrum-4, #3ddc97)", h2: "var(--spectrum-3, #45d3e8)" },
  { label: "Zephyr", hue: "var(--spectrum-1, #8b5cf6)", h2: "var(--spectrum-4, #3ddc97)" },
];
---

<section class="mq" aria-label="Scrolling showcase">
  <div class="mq__stage">
    <div class="mq__row mq__row--a">
      <div class="mq__track">
        {[...rowA, ...rowA].map((c, i) => (
          <span class="mq__card" style={`--h1:${c.hue};--h2:${c.h2}`} aria-hidden={i >= rowA.length ? "true" : null}>
            <span class="mq__card-tag">{c.label}</span>
          </span>
        ))}
      </div>
    </div>

    <div class="mq__row mq__row--b">
      <div class="mq__track">
        {[...rowB, ...rowB].map((c, i) => (
          <span class="mq__card" style={`--h1:${c.hue};--h2:${c.h2}`} aria-hidden={i >= rowB.length ? "true" : null}>
            <span class="mq__card-tag">{c.label}</span>
          </span>
        ))}
      </div>
    </div>
  </div>
</section>

<style>
  .mq {
    min-height: 440px;
    display: grid;
    place-items: center;
    overflow: hidden;
    padding: clamp(1.5rem, 1rem + 2vw, 3rem) 0;
    background:
      radial-gradient(80% 120% at 50% -10%, color-mix(in oklab, var(--spectrum-1, #8b5cf6) 10%, transparent), transparent 60%),
      var(--bg, #0a0b12);
    perspective: 1400px;
  }

  .mq__stage {
    display: grid;
    gap: clamp(0.8rem, 0.5rem + 1vw, 1.4rem);
    width: 100%;
    transform: rotateX(14deg) rotateZ(-2deg);
    transform-style: preserve-3d;
  }

  /* each row is the mask window; the track inside scrolls */
  .mq__row {
    --fade: clamp(2rem, 8vw, 7rem);
    overflow: hidden;
    -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade), #000 calc(100% - var(--fade)), transparent);
    mask-image: linear-gradient(to right, transparent, #000 var(--fade), #000 calc(100% - var(--fade)), transparent);
  }

  .mq__track {
    display: flex;
    gap: clamp(0.7rem, 0.4rem + 0.9vw, 1.2rem);
    width: max-content;
    will-change: transform;
  }
  /* the two halves are identical, so translating by -50% is seamless */
  .mq__row--a .mq__track { animation: mq-left 34s linear infinite; }
  .mq__row--b .mq__track { animation: mq-right 30s linear infinite; }

  /* pause the belt when the visitor is inspecting a row */
  .mq__row:hover .mq__track { animation-play-state: paused; }

  .mq__card {
    flex: 0 0 auto;
    width: clamp(9rem, 7rem + 10vw, 14rem);
    aspect-ratio: 4 / 3;
    border-radius: var(--radius-m, 14px);
    position: relative;
    overflow: hidden;
    display: grid;
    align-items: end;
    padding: 0.8rem;
    box-shadow: var(--shadow-m, 0 10px 26px rgba(0, 0, 0, 0.3));
    background:
      linear-gradient(145deg, color-mix(in oklab, var(--h1) 88%, white 6%), color-mix(in oklab, var(--h2) 78%, black 22%));
    transform: skewX(-6deg);
    transition: transform var(--dur, 0.32s) var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
      box-shadow var(--dur, 0.32s) var(--ease-out, ease);
  }
  /* inner sheen so tiles read as glassy artwork, not flat swatches */
  .mq__card::before {
    content: "";
    position: absolute;
    inset: 0;
    background:
      radial-gradient(120% 80% at 15% 10%, rgba(255, 255, 255, 0.35), transparent 55%),
      repeating-linear-gradient(60deg, transparent 0 8px, rgba(0, 0, 0, 0.06) 8px 9px);
    mix-blend-mode: soft-light;
  }
  .mq__card:hover {
    transform: skewX(-6deg) translateY(-6px) scale(1.03);
    box-shadow: var(--shadow-l, 0 20px 50px rgba(0, 0, 0, 0.4));
  }

  .mq__card-tag {
    position: relative;
    font-family: var(--font-mono, monospace);
    font-size: var(--step--1, 0.8rem);
    font-weight: 600;
    letter-spacing: 0.08em;
    color: #fff;
    text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
    transform: skewX(6deg); /* counter the card skew so text sits straight */
  }

  @keyframes mq-left {
    from { transform: translateX(0); }
    to { transform: translateX(-50%); }
  }
  @keyframes mq-right {
    from { transform: translateX(-50%); }
    to { transform: translateX(0); }
  }

  @media (prefers-reduced-motion: reduce) {
    .mq__stage { transform: none; }
    .mq__track { animation: none !important; }
    .mq__row {
      overflow-x: auto;
      scrollbar-width: thin;
    }
    .mq__card { transform: none; }
    .mq__card-tag { transform: none; }
    .mq__card:hover { transform: translateY(-4px); }
  }
</style>