All components

Eduardbodak Animated Cards

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

Open live demo ↗ Raw prompt (.md)

What it does

A scroll-driven card sequence with Lenis smooth scroll where GSAP ScrollTrigger scrubs three stacked hero cards apart and fades them out, then pins the services section and, using per-card progress math on scrub, flies three floating cards up from tiny scaled thumbnails into full-size, rotating them 180deg on the Y axis to flip from front to back.

How it's built

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

scroll scrub pinning cards 3d-flip lenis smoothstep editorial minimal

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

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 2rem and NO background — it's a transparent window so the fixed .cards layer 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-cards opacity = interpolate(1 → 0.5, smoothStep(progress)).
  • For each of ["#hero-card-1", "#hero-card-2", "#hero-card-3"] at index 0/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 via gsap.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): x stays "0%", rotation stays 0.

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) and gsap.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%" over cardProgress/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 ; else 1.
  • opacity:
  • < 0.2: smoothStep(cardProgress / 0.2) (fade 0→1 in the first fifth) ; else 1.
  • 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: with n = (cardProgress - 0.6) / 0.4: interpolate x → "0%" and rotate → 0 with smoothStep(n), and rotationY = 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; the onLeave/onEnterBack position 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-cards widens to calc(100% - 4rem). .services becomes auto-height (min-height:100svh; height:100%), .services-header resets to translateY(0). .mobile-cards becomes visible as a static stacked column (display:block, cards margin-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 .cards section stays behind the opaque sections and effectively unseen.
  • Use 100svh for 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).