Astro Rocket’s homepage has a new section, tucked in just above the call to action: the Stack Marquee. It’s two rows of tech-stack cards that scroll in opposite directions — an endless, soft-edged strip of the tools the theme is built on. It runs on pure CSS, recolours itself with your active theme, and is driven entirely by the src/content/stack collection. It’s also deliberately absent on phones. Here’s how all of that works.
A seamless loop, with zero JavaScript
The trick behind every infinite marquee is duplication. Each row holds its list of cards twice over, and a single CSS keyframe slides the whole track left by exactly half its width. Because the second copy is identical to the first, the instant the animation loops back to the start there’s nothing to give it away — the join is invisible.
@keyframes stack-marquee-scroll {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(calc(-50% - (var(--marquee-gap) / 2)), 0, 0); }
}
There’s no JavaScript, no requestAnimationFrame, no scroll listener — just a GPU-friendly transform animation that the browser can run on its own thread.
The two rows don’t move as one. The first row scrolls one way; the second reverses the list, flips its direction, and runs a touch faster, so the lanes never march in lockstep. The edges are masked with a CSS gradient so cards fade in and out instead of popping at the boundary, and hovering a row pauses it so you can read — or click — a card.
Filling the loop on wide screens
The -50% trick only hides the join when one copy of the row is at least as wide as the container. The theme ships with four tools, and on a wide desktop four cards aren’t quite wide enough to span the screen — so the loop would flash a small gap at the seam (tablets and phones, being narrower, were fine).
The fix is to repeat the list up to a minimum card count before it gets duplicated, so a single copy always overflows even the widest container. The maths above is untouched, because the track is still exactly two identical copies:
const MIN_CARDS = 8;
const fill = (list) => {
if (!list.length) return list;
const out = [...list];
while (out.length < MIN_CARDS) out.push(...list);
return out;
};
Driven by your content
Every card comes from src/content/stack — one MDX file per tool, each with a name, an icon, and a link. Add or remove a tool by editing that folder and both the marquee and the static tech grid on the About page update on their own; there’s nothing to wire up in the component.
Each card is the same Card and Icon you’ll find everywhere else in the component library, so it inherits the theme’s spacing, borders, and — through a single brand token — its colour. Switch themes and the entire strip recolours instantly.
Why it sits out on phones
On a phone, the marquee is hidden entirely. A horizontally-scrolling, auto-animating strip wants room: on a narrow screen only a card or two fit at a time, the effect is lost, and a constantly-moving block competes for attention while someone is trying to read. Rather than ship a cramped version, the homepage simply leaves it out below tablet size — the page reads cleaner and renders a little less.
Catching every phone takes two rules. Portrait phones are the easy half — they’re narrow, so a width query handles them:
@media (max-width: 767px) {
.stack-marquee-section { display: none; }
}
Landscape phones are the tricky half. They’re wider than the tablet breakpoint, so a width query alone would let them slip through and show the marquee. What gives them away is that they’re short and touch-driven, so a second rule catches them by their height and pointer type — without ever touching a real tablet or a laptop:
@media (orientation: landscape) and (max-height: 500px) and (pointer: coarse) {
.stack-marquee-section { display: none; }
}
Keeping the zebra intact
The homepage alternates two background tones section by section — a zebra stripe running down the page. Dropping a section in or out could easily break that rhythm, so the marquee is careful about it.
When it’s hidden, the whole <section> is removed from the flow with display: none (not just visually hidden), so the Blog section and the CTA become direct neighbours and keep alternating. When it’s shown, it slots in as a background stripe, and the CTA and footer below it swap tones so the run stays perfectly two-coloured from top to bottom — no third colour required. If you want the tokens behind those two tones, the colour token system post covers them.
It respects reduced motion
Like every piece of motion in the theme, the marquee defers to prefers-reduced-motion. If a visitor has asked their system to go easy on animation, the scroll is switched off and each row becomes a normal, manually-scrollable strip — all the content is still there, it just stops moving on its own:
@media (prefers-reduced-motion: reduce) {
.stack-marquee-track { animation: none; transform: none; }
.stack-marquee-row { overflow-x: auto; }
}
For more on how the theme handles motion responsibly, see Animations in Astro Rocket.
Using it
The component lives at src/components/landing/StackMarquee.astro. Drop it into any section and it pulls from your stack collection automatically:
<StackMarquee />
It accepts props for the number of rows, scroll speed, card width, edge fade, and whether the cards link out — but the defaults are tuned for the homepage, where it sits in its own band directly above the CTA.
Related reading
- Animations in Astro Rocket — the theme’s approach to motion, performance, and reduced-motion.
- The Component Library — the
CardandIconthe marquee is built from. - Scroll Progress Ring — another small, GPU-friendly piece of motion.
- Design System Colour Tokens — the background tones behind the zebra.