All components

MaximaTherapy Sticky Cards

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Scroll-pinned hero (700svh of scrub scroll, Lenis-smoothed) where a stack of cards rises into view while the headline exits upward; past the halfway mark the front card 3D-flips with an elastic rotationY to reveal four tilted colored cards, which then get dismissed upward one by one with increasing rotation across staggered progress ranges.

How it's built

Categorycards
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

sticky stacked-cards scroll pin scrub 3d-flip elastic lenis

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 plugin ScrollTrigger (gsap/ScrollTrigger), registered via gsap.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.js as a module, plus the nomodule fallback). 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;.

  1. section.hero — contains two absolutely-positioned overlays:
  2. div.hero-contenth1 headline. Neutral copy, e.g. "Scroll down and watch everything fall into place".
  3. div.sticky-cards → the card stack. It holds, in this DOM order:
  4. 1 div.card.card-front with h3 ("First Frame"), a span badge ("Start here"), a p, and a div.icon (an ion-icon such as caret-down).
  5. 4 div.card.card-back, each with id="card-1"id="card-4", each containing an h3 title, a div.icon with an ion-icon, and a p. Suggested titles/icons: card-1 "Final Hold" (lock-open), card-2 "Layered Time" (layers), card-3 "Weight & Flow" (prism), card-4 "Soft Motion" (infinite).
  6. section.about — a centered h3 with 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-front overrides transform to translate(-50%, 50%) rotateY(0deg) and sets backface-visibility: hidden. Its .icon is a 4rem circle with a 0.125rem white border.
  • .card-back overrides transform to translate(-50%, 50%) rotateY(180deg) and sets backface-visibility: hidden. Its .icon is a 5rem filled white circle, font-size 1.5rem, colored --base-400.
  • Per-card back colors: #card-1 bg --base-400/text --base-100; #card-2 bg --base-500/text --base-800; #card-3 bg --base-600/text --base-100; #card-4 bg --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():

  • frontStickyCardrotationY: 180
  • each back card irotationY: 0, rotationZ: cardFlipTiltAngles[i]

concealBackCards() (reverse):

  • frontStickyCardrotationY: 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:

  1. Enter phaseenterProgress = clamp(0, 1, mapRange(0, svhToProgress(100), 0, 1, progress)) (i.e. progress 0 → 0.1428 maps to 0 → 1, clamped after).
  2. gsap.set(stickyCards, { y: mapRange(0, 1, 50, -50, enterProgress) + "%" })all five cards rise from y: 50% to y: -50%.
  3. gsap.set(heroHeadline, { y: mapRange(0, 1, 0, -100, enterProgress) + "%" }) — headline exits from y: 0% to y: -100%.
  1. Flip trigger (one-shot, latched by isFlipped):
  2. if progress > svhToProgress(200) (0.2857) and !isFlippedrevealBackCards(); isFlipped = true;
  3. else if progress <= 0.2857 and isFlippedconcealBackCards(); isFlipped = false;
  1. Dismiss phase — for each back card i, read its [dismissStart, dismissEnd] window, compute dismissProgress = clamp(0, 1, mapRange(dismissStart, dismissEnd, 0, 1, progress)), then:
  2. gsap.set(card, { y: mapRange(0, 1, -50, -250, dismissProgress) + "%", rotation: mapRange(0, 1, cardFlipTiltAngles[i], cardDismissTiltAngles[i], dismissProgress) }).
  3. So each back card slides from y: -50% up to y: -250% while its z-rotation ramps from its flip tilt to its dismiss tilt, only within its own scroll window. Note rotation here is the 2D z-rotation (alias of rotationZ), 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 height of scroll, so the page is tall. pinSpacing: true reserves 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 h1 and .about h3 to 85%. Cards keep min-width: 300px.
  • Keep perspective, transform-style: preserve-3d, and backface-visibility: hidden — without them the 3D flip and the hide-the-reverse-face behavior break.