Scroll-Driven Card Deal & 3D Flip Sequence
Goal
Build a multi-section scroll page (with Lenis smooth scroll) where three pastel process cards — "Plan 01", "Design 02", "Develop 03" — star in a two-act scroll story. Act 1: as you scroll past the hero, the three stacked cards fan apart (outer cards slide sideways and tilt ±15°) while dropping down, shrinking and fading. Act 2: a pinned "services" section (pinned for 4 viewport heights) where the same three cards fly up from tiny 0.25-scale thumbnails parked above the viewport, settle into a centered row at full size, then each flips 180° on the Y axis to reveal a white back listing that phase's deliverables. All the scroll motion is scrub-driven via ScrollTrigger.onUpdate with gsap.set + manual smoothstep interpolation (no tweens), while a CSS keyframe animation keeps the cards gently floating the whole time.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scroll. Register with gsap.registerPlugin(ScrollTrigger). Everything runs inside a DOMContentLoaded listener.
Lenis wiring (standard GSAP integration):
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Layout / HTML
Class names are load-bearing — the JS/CSS query them.
<nav>
<div class="logo"><span>Site Logo</span></div>
<div class="menu-btn"><span>Menu</span></div>
</nav>
<section class="hero">
<div class="hero-cards">
<div class="card" id="hero-card-1"> … </div>
<div class="card" id="hero-card-2"> … </div>
<div class="card" id="hero-card-3"> … </div>
</div>
</section>
<section class="about"><h1>Keep scrolling — it gets good</h1></section>
<section class="services">
<div class="services-header"><h1>Stuff I make so you don’t have to</h1></div>
<div class="mobile-cards">
<div class="cards-container">
<div class="card" id="mobile-card-1"> …flip card markup… </div>
<div class="card" id="mobile-card-2"> … </div>
<div class="card" id="mobile-card-3"> … </div>
</div>
</div>
</section>
<section class="cards">
<div class="cards-container">
<div class="card" id="card-1"> …flip card markup… </div>
<div class="card" id="card-2"> … </div>
<div class="card" id="card-3"> … </div>
</div>
</section>
<section class="outro"><h1>The story’s not over yet</h1></section>
Hero card markup (each #hero-card-N) — two mirrored title rows, no flip structure:
<div class="card-title"><span>Plan</span><span>01</span></div> <!-- top row: word left, number right -->
<div class="card-title"><span>01</span><span>Plan</span></div> <!-- bottom row: mirrored -->
Card 1 = Plan/01, card 2 = Design/02, card 3 = Develop/03.
Flip card markup (each #card-N and #mobile-card-N):
<div class="card-wrapper">
<div class="flip-card-inner">
<div class="flip-card-front">
<div class="card-title"><span>Plan</span><span>01</span></div>
<div class="card-title"><span>01</span><span>Plan</span></div>
</div>
<div class="flip-card-back">
<div class="card-title"><span>Plan</span><span>01</span></div>
<div class="card-copy">
<p>Discovery</p><p>Audit</p><p>User Flow</p><p>Site Map</p><p>Personas</p><p>Strategy</p>
</div>
<div class="card-title"><span>01</span><span>Plan</span></div>
</div>
</div>
</div>
Back-copy lists (6 items each):
- Card 1 "Plan / 01": Discovery, Audit, User Flow, Site Map, Personas, Strategy.
- Card 2 "Design / 02": Wireframes, UI Kits, Prototypes, Visual Style, Interaction, Design QA.
- Card 3 "Develop / 03": HTML/CSS/JS, CMS Build, GSAP Motion, Responsive, Optimization, Launch.
Styling
Fonts (Google Fonts): DM Sans (body, headings) and DM Mono (all spans). Import both.
Palette (CSS vars): --dark: #000, --light: #f9f4eb (warm off-white), --light2: #f0ece5, --accent-1: #e5d9f6 (lavender), --accent-2: #ffd2f3 (pink), --accent-3: #fcdca6 (peach).
Global: * { margin:0; padding:0; box-sizing:border-box }; body { font-family:"DM Sans" }; h1 { font-size:1.5rem; font-weight:500 }; p { font-size:1.1rem; font-weight:500 }; span { text-transform:uppercase; font-family:"DM Mono"; font-size:0.75rem; font-weight:500 }.
nav: position:fixed; width:100vw; padding:2rem; display:flex; justify-content:space-between; align-items:center; z-index:2. .logo span and .menu-btn span are small pills: font-size:0.8rem; padding:0.75rem; border-radius:0.25rem — logo pill dark bg / light text, menu pill --light2 bg / dark text.
section: position:relative; width:100vw; height:100svh; padding:2rem; overflow:hidden.
.hero: background--light, dark text..about,.outro: dark bg, light text, flex-centered h1..services:padding:8rem 2remand NO background — it's a transparent window so the fixed.cardslayer behind it shows through.
.hero-cards: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:35%; display:flex; justify-content:center; gap:1rem. Each .hero-cards .card: flex:1; position:relative; aspect-ratio:5/7; padding:0.75rem; border-radius:0.5rem; display:flex; flex-direction:column; justify-content:space-between; spans at 0.7rem. .card-title: width:100%; display:flex; justify-content:space-between. Per card: #hero-card-1 bg --accent-1, transform-origin: top right, z-index:2; #hero-card-2 bg --accent-2, z-index:1; #hero-card-3 bg --accent-3, transform-origin: top left, z-index:0.
.services-header: position:relative; width:100%; text-align:center; transform:translateY(400%); will-change:transform (starts pushed 4 line-heights below; the JS scrubs it up).
.cards (the fixed flip-card stage): position:fixed; top:0; left:0; width:100vw; height:100svh; display:flex; justify-content:center; z-index:-1; background-color:var(--light). Because it's z-index -1, the opaque hero/about/outro sections cover it — it's only visible through the transparent .services section.
.cards-container: position:relative; width:75%; height:100%; margin-top:4rem; display:flex; justify-content:center; align-items:center; gap:4rem. Each .cards-container .card: flex:1; position:relative; aspect-ratio:5/7; perspective:1000px.
.card-wrapper: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; height:100% plus an infinite CSS floating animation:
animation: floating 2s infinite ease-in-out;
@keyframes floating {
0% { transform: translate(-50%, -50%); }
50% { transform: translate(-50%, -55%); }
100% { transform: translate(-50%, -50%); }
}
Stagger the float per card with animation-delay: card-1 0, card-2 0.25s, card-3 0.5s.
Flip structure: .flip-card-inner { position:relative; width:100%; height:100%; transform-style:preserve-3d }. .flip-card-front, .flip-card-back { position:absolute; width:100%; height:100%; border-radius:1rem; backface-visibility:hidden; overflow:hidden }. Front: padding:1rem; display:flex; flex-direction:column; justify-content:space-between; align-items:center; bg per card = accent-1 / accent-2 / accent-3. Back: padding:1rem; display:flex; flex-direction:column; justify-content:space-between; gap:2rem; background:#fff; transform:rotateY(180deg). .card-copy { width:100%; height:100%; display:flex; flex-direction:column; gap:0.5rem }; each .card-copy p { flex:1; display:flex; justify-content:center; align-items:center; font-size:1rem; background:var(--light2); border-radius:0.25rem }.
Initial resting transforms of the flip cards (in CSS — these match the scrub's start values so there's no jump):
.cards #card-1 { transform: translateX(100%) translateY(-100%) rotate(-5deg) scale(0.25); z-index:2; }
.cards #card-2 { transform: translateX(0%) translateY(-100%) rotate(0deg) scale(0.25); z-index:1; }
.cards #card-3 { transform: translateX(-100%) translateY(-100%) rotate(5deg) scale(0.25); z-index:0; }
.cards .cards-container .card { opacity: 0; }
.mobile-cards { display:none } on desktop.
GSAP effect (be exact)
All scroll animation is desktop-only: wrap every ScrollTrigger in if (window.innerWidth > 1000) { … }.
Define the easing helper used for ALL interpolation:
const smoothStep = (p) => p * p * (3 - 2 * p); // classic smoothstep
There are no tweens — every ScrollTrigger uses onUpdate + gsap.utils.interpolate / gsap.utils.clamp + gsap.set. Four ScrollTriggers total:
ScrollTrigger 1 — hero cards fan apart (scrubbed)
ScrollTrigger.create({ trigger: ".hero", start: "top top", end: "75% top", scrub: 1, onUpdate })
In onUpdate with progress = self.progress:
- Container fade:
.hero-cardsopacity = interpolate(1 → 0.5,smoothStep(progress)). - For each of
["#hero-card-1", "#hero-card-2", "#hero-card-3"]atindex0/1/2, compute a staggered per-card progress:
``js const delay = index * 0.9; const cardProgress = clamp(0, 1, (progress - delay * 0.1) / (1 - delay * 0.1)); `` (so card 1 starts immediately, card 2 at 9% progress, card 3 at 18%, each still finishing at 1).
- Every value below is interpolated with
smoothStep(cardProgress)and applied viagsap.set(cardId, { x, y, rotation, scale }): y:"0%" → "350%"(all cards drop far below).scale:1 → 0.75.- index 0 (left card):
x: "0%" → "90%",rotation: 0 → -15. - index 2 (right card):
x: "0%" → "-90%",rotation: 0 → 15. - index 1 (middle):
xstays"0%",rotationstays0.
Net effect: the outer cards slide inward/over the middle one (crossing paths thanks to the top right / top left transform-origins) while tilting outward, and all three sink away shrinking as the hero scrolls out.
ScrollTrigger 2 — pin the services section
ScrollTrigger.create({
trigger: ".services", start: "top top",
end: `+=${window.innerHeight * 4}px`,
pin: ".services", pinSpacing: true,
});
ScrollTrigger 3 — release the fixed cards layer after the pin
Same trigger/start/end as #2, no scrub. This swaps the .cards stage between fixed and absolute so it scrolls away with the page once the pinned sequence ends:
onLeave: measure the services section's document offset (window.pageYOffset + getBoundingClientRect().top) andgsap.set(".cards", { position: "absolute", top: servicesTop, left: 0, width: "100vw", height: "100vh" }).onEnterBack:gsap.set(".cards", { position: "fixed", top: 0, left: 0, width: "100vw", height: "100vh" }).
ScrollTrigger 4 — header rise + card fly-in + 3D flip (the main act, scrubbed)
ScrollTrigger.create({ trigger: ".services", start: "top bottom", end: `+=${window.innerHeight * 4}`, scrub: 1, onUpdate })
Note it starts at top bottom (as soon as services enters the viewport) so the animation is already underway when the pin engages.
In onUpdate with progress = self.progress:
Header: headerProgress = clamp(0, 1, progress / 0.9); then gsap.set(".services-header", { y: interpolate("400%", "0%", smoothStep(headerProgress)) }) — the centered heading rides up from below over the first 90% of the range.
Cards: for each of ["#card-1", "#card-2", "#card-3"] at index 0/1/2:
const delay = index * 0.5;
const cardProgress = clamp(0, 1, (progress - delay * 0.1) / (0.9 - delay * 0.1));
(card 1 spans 0→0.9 of progress, card 2 spans 0.05→0.9, card 3 spans 0.10→0.9 — a light stagger, all finishing together at 90%.)
Each card's motion is piecewise over its own cardProgress, every segment eased with smoothStep of the segment-normalized progress:
y(of the outer#card-N):cardProgress < 0.4: interpolate"-100%" → "50%"overcardProgress/0.4(drops in from above and overshoots below center).0.4 ≤ cardProgress < 0.6: interpolate"50%" → "0%"over(cardProgress-0.4)/0.2(settles back up to center).- else:
"0%". scale:< 0.4:0.25 → 0.75;0.4–0.6:0.75 → 1; else1.opacity:< 0.2:smoothStep(cardProgress / 0.2)(fade 0→1 in the first fifth) ; else1.x/rotate/rotationY(x and rotate on the card; rotationY on that card's.flip-card-inner):cardProgress < 0.6: hold the fanned pose —x="100%"/"0%"/"-100%"for index 0/1/2,rotate=-5/0/5,rotationY = 0.0.6 ≤ cardProgress < 1: withn = (cardProgress - 0.6) / 0.4: interpolatex → "0%"androtate → 0withsmoothStep(n), androtationY = smoothStep(n) * 180— the cards glide from the fanned positions into a tight centered row WHILE flipping to their white backs.≥ 1:x: "0%",rotate: 0,rotationY: 180.
Apply per frame:
gsap.set(cardId, { opacity, y, x, rotate, scale });
gsap.set(innerCard, { rotationY }); // innerCard = `${cardId} .flip-card-inner`
Because the CSS floating keyframes live on .card-wrapper (between the transformed .card and the flipping .flip-card-inner), the bobbing float persists throughout and composes with the scrubbed transforms.
Assets / images
None — the whole piece is typographic: pastel solid-color cards, mono uppercase labels, and solid dark/light section backgrounds. No images to source.
Behavior notes
- Scrub everywhere (
scrub: 1), so scrolling backwards plays every phase in reverse; theonLeave/onEnterBackposition swap keeps the flip-card stage anchored after the pin ends and re-fixes it when scrolling back up. - Responsive (
max-width: 1000px): all ScrollTriggers are skipped..hero-cardswidens tocalc(100% - 4rem)..servicesbecomes auto-height (min-height:100svh; height:100%),.services-headerresets totranslateY(0)..mobile-cardsbecomes visible as a static stacked column (display:block, cardsmargin-bottom:2rem, no floating animation) with the flip pre-resolved in CSS:.mobile-cards .flip-card-front { transform: rotateY(180deg) }and.mobile-cards .flip-card-back { transform: rotateY(0deg) }so the white backs show. The fixed.cardssection stays behind the opaque sections and effectively unseen. - Use
100svhfor section heights so mobile browser chrome doesn't clip layouts. - The page must be tall enough to scroll: hero (100svh) → about (100svh) → services (pinned +4×100vh) → outro (100svh).