Scroll-Driven Arc Card Carousel with Synced Step Counter
Goal
Build a single full-screen pinned section where a row of image cards is laid out along a circular arc (like cards resting on the rim of a giant wheel below the viewport) and rotates through the top of the screen as you scroll — each card swings up from the right, passes upright through the center, and swings off to the left, staying tangent to the arc the whole way. A large step counter (01–05) in the corner slides vertically to stay in sync with whichever card is currently centered. Smooth scroll via Lenis. The card positions are recomputed every frame with trigonometry (cos/sin) and applied through gsap.set — there is no tween/timeline on the cards themselves; only the counter uses a tween.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scroll. No other plugins, no framework:
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
Register once: gsap.registerPlugin(ScrollTrigger);. Wrap all setup in a DOMContentLoaded listener. Card positioning is done with a native IntersectionObserver (for the counter) plus GSAP.
Layout / HTML
Everything lives inside a <div class="container">. Structure top to bottom:
<nav>— a<p id="logo">readingVoxel(fictional brand) and a<button>readingDownload Now. Absolutely positioned, spans the top of the viewport,z-index: 2.<section class="intro">— empty; it is just a full-viewport panel with a full-bleed background image.<section class="steps">— the pinned stage. Contains two blocks:<div class="step-counter">→<div class="counter-title"><h1>steps</h1></div>and<div class="count"><div class="count-container"> <h1>01</h1> <h1>02</h1> <h1>03</h1> <h1>04</h1> <h1>05</h1> </div></div>. The five number<h1>s are stacked vertically inside.count-container; only one shows at a time through the clipped.countwindow.<div class="cards">→ seven<div class="card">elements. The first five each contain<div class="card-img"><img src="…" alt="" /></div>and<div class="card-content"><p>…</p></div>. The last two are<div class="card empty"><p>EMPTY</p></div>(invisible spacers — see note). All seven must exist; the arc math depends on the count being 7.<section class="outro">— a single centered<p>with one highlighted<span>, e.g.Our 3D design tool is built to enhance your creative workflow, <span>providing an all-in-one solution</span> for crafting stunning visuals and prototypes.
Card paragraph copy (steps 1→5, neutral product copy):
- "Effortlessly import your 3D models and assets into our intuitive design tool, ensuring that projects are set up quickly."
- "Take full control of your designs with our advanced customization tools. Adjust lighting and geometry with precision."
- "Bring your designs to life with our seamless animation presets and tools, allowing you to create dynamic interactions."
- "Collaborate in real time with your team, sharing ideas and updates instantly to streamline the creative process."
- "When your design is complete, export it in various formats optimized for production or further editing."
Styling
Palette & type:
- Page:
background: #000; color: #fff;. Bodyfont-family: "Helvetica Now Display"(a neutral grotesque sans; system sans fallback is fine). - Logo and all big display type:
font-family: "PP Monument Extended"; font-weight: 900; text-transform: uppercase;(a heavy, extra-wide extended display face). button:color: #000; background: #fff; border: none; padding: 0.75em 1em; border-radius: 0.25em; font-weight: 500;..outro:display:flex; justify-content:center; align-items:center; background: linear-gradient(180deg, #000000, #364549);..outro p:width:75%; text-align:center; font-size:52px; font-weight:400; line-height:1.125;..outro p span { color:#75e1ff; }(cyan highlight).img { width:100%; height:100%; object-fit:cover; }. Global reset* { margin:0; padding:0; box-sizing:border-box; }.
Critical structural CSS (the effect hangs on these exact values):
section { position:relative; width:100vw; height:100vh; overflow:hidden; }html, body { width:100vw; height:900vh; }(tall page; the real scroll distance is actually created by the pin spacer, below)..intro { background: url("…hero…") no-repeat 50% 50%; background-size:cover; }.cards { position:absolute; top:25%; left:50%; transform:translate(-50%,-50%); width:150vw; height:600px; will-change:transform; }— the card layer is centered horizontally and sits with its center at 25% of viewport height (high up, so the arc's top passes through the upper third)..card { position:absolute; width:500px; height:550px; left:50%; top:50%; margin-left:-250px; transform-origin:center center; display:flex; flex-direction:column; gap:1em; will-change:transform; }— every card is stacked at the same origin point; GSAP then moves each one out onto the arc..card-img { flex:1; background:#fff; border-radius:0.5em; overflow:hidden; }.card-content { width:100%; height:60px; }with.card-content p { font-size:16px; font-weight:500; line-height:1.25; text-align:left; }.empty { opacity:0; }— the two spacer cards are laid out on the arc but invisible.- Step counter:
.step-counter { position:absolute; display:flex; flex-direction:column; margin:2em; }.counter-title, .count { position:relative; width:1200px; height:150px; overflow:hidden; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%); }(a clipped 150px-tall window)..count { top:-10px; }.count-container { position:relative; transform:translateY(150px); will-change:transform; }— starts translated down 150px (initial rest state = blank window above "01")..step-counter h1 { font-size:150px; line-height:1; letter-spacing:-0.04em; font-weight:900; text-transform:uppercase; }— so each number is exactly 150px tall = one window height. Stacking five of them makes a 750px column that slides through the 150px window.
GSAP effect (be exhaustive — this is the whole component)
Smooth-scroll wiring (Lenis + GSAP ticker)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Default Lenis options. Lenis scroll events feed ScrollTrigger.update; the GSAP ticker drives lenis.raf; lag smoothing is disabled so the scrub stays glued to scroll position.
Constants
const stickySection = document.querySelector(".steps");
const stickyHeight = window.innerHeight * 7; // 7 viewport heights of scroll while pinned
const cards = document.querySelectorAll(".card"); // 7 (5 real + 2 empty)
const countContainer= document.querySelector(".count-container");
const totalCards = cards.length; // 7
const arcAngle = Math.PI * 0.4; // 72° arc span
const startAngle = Math.PI / 2 - arcAngle / 2; // 54° (arc runs 54°→126°, centered on the vertical 90°)
getRadius() is responsive:
const getRadius = () =>
window.innerWidth < 900 ? window.innerWidth * 7.5 : window.innerWidth * 2.5;
So on desktop the arc radius is 2.5 × viewport width (a very shallow, wide arc — the wheel is huge and mostly below the screen).
The ScrollTrigger (single pinned, no scrub — driven by onUpdate)
ScrollTrigger.create({
trigger: stickySection,
start: "top top",
end: `+=${stickyHeight}px`, // pin lasts innerHeight*7
pin: true,
pinSpacing: true,
onUpdate: (self) => { positionCards(self.progress); },
});
- The
.stepssection pins attop topand stays pinned for 7× the viewport height of scroll.pinSpacing:trueinserts a real spacer so the outro appears only after the whole arc sequence finishes. - No
scruband no tween — every frame,onUpdaterecomputes card transforms fromself.progress(0→1) viapositionCards. Smoothness comes entirely from Lenis. CallpositionCards(0)once at startup so the initial pose is correct before any scroll.
positionCards(progress) — the trigonometric layout (the star effect)
function positionCards(progress = 0) {
const radius = getRadius();
const totalTravel = 1 + totalCards / 7.5; // 1 + 7/7.5 = 1.9333…
const adjustedProgress = (progress * totalTravel - 1) * 0.75; // ranges −0.75 → +0.70
cards.forEach((card, i) => {
const normalizedProgress = (totalCards - 1 - i) / totalCards; // i=0 → 6/7 … i=6 → 0
const cardProgress = normalizedProgress + adjustedProgress;
const angle = startAngle + arcAngle * cardProgress;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
const rotation = (angle - Math.PI / 2) * (180 / Math.PI); // degrees off vertical
gsap.set(card, {
x: x,
y: -y + radius, // radius*(1 − sin θ): 0 at the top (θ=90°), grows downward off-axis
rotation: -rotation, // keep the card tangent to the arc (upright at the top)
transformOrigin: "center center",
});
});
}
Exact mechanics to reproduce:
- Each card
iis given a fixed phase offsetnormalizedProgress = (totalCards−1−i)/totalCards, so the seven cards are evenly spread along the arc,1/7apart, with the first DOM card (i=0) furthest along (largest angle) and the last (i=6) furthest behind. adjustedProgressshifts every card's phase together as you scroll: atprogress=0it is(0·1.9333−1)·0.75 = −0.75; atprogress=1it is(1.9333−1)·0.75 = +0.70. That's the conveyor that walks all cards along the arc.angle = startAngle + arcAngle·cardProgress. A card is centered/upright whenangle = 90° (π/2), i.e.cardProgress = 0.5. As the sharedadjustedProgressclimbs from −0.75→+0.70, cards reachcardProgress=0.5one after another in DOM order (i=0 first, i=6 last) — so card-1 swings through center first, then card-2, and so on.- Position:
x = cos(angle)·radius(right of center when angle<90°, left when >90°);y = −sin(angle)·radius + radius = radius·(1 − sin angle)— 0 at the top of the arc, increasing (pushing the card down and off-screen) as it moves away from center. The circle's center is effectivelyradiusbelow the card layer's center, so cards ride the top edge of a huge wheel. - Rotation:
rotation: -(angle − 90°)in degrees — the card is rotated to stay tangent to the arc (perfectly upright at the top, tilting clockwise as it moves right, counter-clockwise to the left). - Everything is applied with
gsap.set(instantaneous per frame), never a tween.will-change: transformis on.cardsand.card.
Synced step counter (IntersectionObserver + one tween)
A native IntersectionObserver watches each card and slides the number column so the centered card's number shows:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const cardIndex = Array.from(cards).indexOf(entry.target);
const targetY = 150 - cardIndex * 150; // 150, 0, −150, −300, −450, −600, −750
gsap.to(countContainer, {
y: targetY,
duration: 0.3,
ease: "power1.out",
overwrite: true,
});
}
});
}, { root: null, rootMargin: "0% 0%", threshold: 0.5 });
cards.forEach((card) => observer.observe(card));
threshold: 0.5→ the callback fires when a card is at least 50% visible (i.e. as it reaches the center of the screen). The centered card's DOM index setstargetY = 150 − index·150.- The
.count-containerstarts attranslateY(150px)(blank window). Because each number<h1>is exactly 150px tall,y=0reveals "01",y=−150reveals "02", …y=−600reveals "05". The tween (power1.out,0.3s,overwrite:true) slides the correct number into the clipped window as each successive card passes center — the counter reads 01 → 05 in lockstep with the arc. (The two empty spacer cards also trigger the observer at the tail, which is why five numbers plus padding cover seven cards.)
Resize
window.addEventListener("resize", () => positionCards(0));
On resize just re-run positionCards(0) (recomputes radius from the new width). No ScrollTrigger.refresh call in the original.
Assets / images
- 1 full-bleed hero background for the
.intropanel (set as a CSSbackground,cover, centered) — a moody, dark, atmospheric editorial/technical image; any aspect ratio (it'scover). - 5 card images, one per content card, each filling a rounded (
0.5em) white-matted frame roughly 500×490px (portrait-ish,object-fit:cover). They are a cohesive set of abstract 3D / product-design visuals matching the five step captions: (1) importing 3D models/assets, (2) customization — lighting & geometry controls, (3) animation presets, (4) real-time collaboration, (5) export/production. Clean, rendered, tech-forward look; cohesive dark or neutral palette. Provide 5 files in step order; if fewer are available, repeat in order.
No client logos or real brand marks — the demo brand is the fictional "Voxel".
Behavior notes
- Page-level component: Lenis takes over the whole page; the
.stepssection pins for 7 viewport heights while the cards rotate through, then releases into the outro. - The two
.emptycards (opacity:0) are not decoration to drop — they are real arc slots that keep the spacing and the counter timing correct; keeptotalCards = 7. - Responsive: at
max-width:900pxthe radius switches toinnerWidth·7.5(even shallower arc),.cardstop moves to27.5%, cards shrink to375×500, and the counter title/number shrink to30px(with.count { top:0; left:-10px; }). - No reduced-motion handling and no autoplay — nothing moves except in response to scroll (cards) or a card crossing center (counter), so the motion is entirely user-driven.