Sticky Cards — Scroll-Pinned 3D Flip & One-by-One Dismiss
Goal
Build a full-screen, scroll-pinned hero where a stack of cards rises into view while the headline slides out the top; past the halfway point a single "front" card performs a springy 3D flip to reveal four tilted, colored cards fanned on top of each other, which are then dismissed upward one at a time as you keep scrolling. The star effect is the elastic 3D card flip driven by a long scrubbed, pinned ScrollTrigger, combined with a staggered per-card dismiss keyed to precise scroll-progress windows.
Tech
Vanilla HTML/CSS/JS with ES module imports. Build under Vite (npm). Use:
gsap(npm) with the pluginScrollTrigger(gsap/ScrollTrigger), registered viagsap.registerPlugin(ScrollTrigger).lenis(npm) for smooth scroll, wired into GSAP's ticker.- Icon glyphs via the ionicons web component (
ion-icon), loaded from its ESM CDN build (ionicons@7.x,ionicons.esm.jsas a module, plus thenomodulefallback). No raster images are used.
Wire Lenis to GSAP exactly like this:
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Layout / HTML
Three stacked full-viewport regions. Every section is position: relative; width: 100%; height: 100svh; overflow: hidden;.
section.hero— contains two absolutely-positioned overlays:div.hero-content→h1headline. Neutral copy, e.g. "Scroll down and watch everything fall into place".div.sticky-cards→ the card stack. It holds, in this DOM order:- 1
div.card.card-frontwithh3("First Frame"), aspanbadge ("Start here"), ap, and adiv.icon(anion-iconsuch ascaret-down). - 4
div.card.card-back, each withid="card-1"…id="card-4", each containing anh3title, adiv.iconwith anion-icon, and ap. Suggested titles/icons: card-1 "Final Hold" (lock-open), card-2 "Layered Time" (layers), card-3 "Weight & Flow" (prism), card-4 "Soft Motion" (infinite). section.about— a centeredh3with a short reflective sentence (e.g. "A quiet progression of motion and stillness, where each layer reveals itself with intention and nothing feels out of place."), width 60% (85% under 1000px), text-align center.
Styling
Fonts (Google Fonts): Barlow Condensed for h1/h3 (weight 900, text-transform: uppercase, line-height: 0.85), DM Sans for body. Sizes: h1 clamp(3rem, 5vw, 7rem); h3 clamp(2rem, 3vw, 5rem); p 1.125rem/weight 450/line-height 1.1; span badge uppercase 0.9rem, padding 0.5rem, radius 0.25rem, white bg, dark text.
Color tokens (CSS variables):
--base-100: #fff--base-200: #fbfff2(hero background)--base-300: #dfebe0(about background)--base-400: #f0fd00(acid yellow — front card + card-1)--base-500: #dfebe0(card-2)--base-600: #8c26fd(violet — card-3)--base-700: #9bfd40(lime — card-4)--base-800: #0f0f0f(near-black text)
.hero bg --base-200, text --base-800. .hero-content is position: absolute; width:100%; height:100svh; flex-centered, will-change: transform; its h1 is width 60%, centered.
Card stack container .sticky-cards: position: absolute; width:100%; height:100svh; overflow:hidden; and critically transform-style: preserve-3d; perspective: 1000px; — this perspective is what makes the flip read as 3D.
Every .card: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:25%; min-width:300px; aspect-ratio:4/5; padding:4rem 2rem; border-radius:1rem; flex column, justify-content:space-between; align-items:center; text-align:center; will-change:transform; default bg --base-400, color --base-100. All five cards therefore occupy the exact same centered spot, stacked.
.card-frontoverrides transform totranslate(-50%, 50%) rotateY(0deg)and setsbackface-visibility: hidden. Its.iconis a 4rem circle with a0.125remwhite border..card-backoverrides transform totranslate(-50%, 50%) rotateY(180deg)and setsbackface-visibility: hidden. Its.iconis a 5rem filled white circle, font-size 1.5rem, colored--base-400.- Per-card back colors:
#card-1bg--base-400/text--base-100;#card-2bg--base-500/text--base-800;#card-3bg--base-600/text--base-100;#card-4bg--base-700/text--base-800.
Because both faces use backface-visibility: hidden: at rest only the single front card is visible; the four back cards are rotated so their reverse faces the viewer and they are invisible until the flip.
.about: flex-centered, bg --base-300, text --base-800, h3 width 60% (85% under 1000px).
GSAP effect (be exhaustive)
Selectors & constants
stickyCards = all .card (5 elements: 1 front + 4 back)
frontStickyCard = .card-front (1)
backStickyCards = all .card-back (4, in DOM order card-1..card-4)
heroHeadline = .hero-content
stickyCardCount = 4
Scroll-length budget expressed in svh units:
CARDS_ENTER_END = 100
CARD_FLIP_TRIGGER = 200
CARD_DISMISS_START = 300
CARD_DISMISS_DURATION = 100
TOTAL_SCROLL_SVH = CARD_DISMISS_START + stickyCardCount * CARD_DISMISS_DURATION = 700
svhToProgress(svh) = svh / TOTAL_SCROLL_SVH // maps an svh milestone to 0..1 progress
totalScroll(px) = window.innerHeight * (TOTAL_SCROLL_SVH / 100) = 7 * innerHeight
Tilt tables (index-aligned to the four back cards in DOM order):
cardFlipTiltAngles = [-10, -20, -5, 10] // rotationZ each card lands on when revealed
cardDismissTiltAngles = [-50, -60, -45, 50] // rotationZ each card ramps to while dismissed
Per-card dismiss windows (reverse order — this matters)
For each back card i (0..3), compute dismissOrder = stickyCardCount - 1 - i, then its progress window is [svhToProgress(CARD_DISMISS_START + dismissOrder*100), svhToProgress(CARD_DISMISS_START + (dismissOrder+1)*100)]. This makes the last card in the DOM dismiss first. Resulting windows (progress 0..1):
- card-4 (i=3) →
[0.4286, 0.5714](dismisses first) - card-3 (i=2) →
[0.5714, 0.7143] - card-2 (i=1) →
[0.7143, 0.8571] - card-1 (i=0) →
[0.8571, 1.0](dismisses last)
Back-to-back, non-overlapping, each spanning 100/700 of progress.
Initial state
gsap.set(frontStickyCard, { rotationY: 0 });
gsap.set(backStickyCards, { rotationY: -180 });
let isFlipped = false;
The flip — imperative one-shot elastic tweens (NOT scrubbed)
Two helper functions fire a single spring animation when the flip threshold is crossed. All tweens: duration: 1, ease: "elastic.out(1, 0.5)".
revealBackCards():
frontStickyCard→rotationY: 180- each back card
i→rotationY: 0,rotationZ: cardFlipTiltAngles[i]
concealBackCards() (reverse):
frontStickyCard→rotationY: 0- each back card →
rotationY: -180,rotationZ: 0
The scrubbed, pinned ScrollTrigger (the driver)
ScrollTrigger.create({
trigger: ".hero",
start: "top top",
end: `+=${totalScroll}px`, // 7 * innerHeight
pin: true,
pinSpacing: true,
scrub: true,
onUpdate: ({ progress }) => { ... }
});
Inside onUpdate, every frame, using gsap.utils.mapRange and gsap.utils.clamp:
- Enter phase —
enterProgress = clamp(0, 1, mapRange(0, svhToProgress(100), 0, 1, progress))(i.e. progress 0 → 0.1428 maps to 0 → 1, clamped after). gsap.set(stickyCards, { y: mapRange(0, 1, 50, -50, enterProgress) + "%" })— all five cards rise fromy: 50%toy: -50%.gsap.set(heroHeadline, { y: mapRange(0, 1, 0, -100, enterProgress) + "%" })— headline exits fromy: 0%toy: -100%.
- Flip trigger (one-shot, latched by
isFlipped): - if
progress > svhToProgress(200)(0.2857) and!isFlipped→revealBackCards(); isFlipped = true; - else if
progress <= 0.2857andisFlipped→concealBackCards(); isFlipped = false;
- Dismiss phase — for each back card
i, read its[dismissStart, dismissEnd]window, computedismissProgress = clamp(0, 1, mapRange(dismissStart, dismissEnd, 0, 1, progress)), then: gsap.set(card, { y: mapRange(0, 1, -50, -250, dismissProgress) + "%", rotation: mapRange(0, 1, cardFlipTiltAngles[i], cardDismissTiltAngles[i], dismissProgress) }).- So each back card slides from
y: -50%up toy: -250%while its z-rotation ramps from its flip tilt to its dismiss tilt, only within its own scroll window. Noterotationhere is the 2D z-rotation (alias ofrotationZ), so it continues from where the flip left it.
Notes on interplay: the enter gsap.set(stickyCards, …) writes y on the back cards too, but since each back card's dismissProgress clamps to 0 before its window opens, its y sits at -50% — continuous with the group. The front card only ever receives the group y, so after the enter phase it rests at y: -50% and then flips away, staying put.
Behavior notes
- Desktop-oriented, scroll-driven; the whole hero is pinned for
7 × viewport heightof scroll, so the page is tall.pinSpacing: truereserves that scroll length. - The flip is a discrete spring (elastic) that plays once each direction as you cross the midpoint; the enter and dismiss motions are continuously scrubbed. Scrolling back up conceals the cards and reverses everything.
- Under
max-width: 1000px, widen.hero-content h1and.about h3to 85%. Cards keepmin-width: 300px. - Keep
perspective,transform-style: preserve-3d, andbackface-visibility: hidden— without them the 3D flip and the hide-the-reverse-face behavior break.