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.
---
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>