All components

Capsules Animated Sticky Cards

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

Open live demo ↗ Raw prompt (.md)

What it does

Scroll-driven deck of pinned full-screen cards where the first card grows from a small rounded capsule into a full-bleed image over 300vh (ScrollTrigger scrub via onUpdate, synced to Lenis), fading out a looping horizontal marquee as it expands. Each following card pins, scales/fades the previous one back, morphs its image border-radius and scale, and reveals SplitText title characters plus description with power4.out.

How it's built

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

sticky sticky-cards scroll pin scrub marquee splittext lenis capsule

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

Capsule-to-Fullscreen Sticky Cards (scroll deck with marquee + SplitText reveals)

Goal

Build a scroll-driven page with a deck of four full-viewport cards that pin on top of each other. The star effect: the first card starts as a small pill-shaped "capsule" (image wrapper scaled to 0.5 with a 400px border-radius) floating over a giant looping horizontal text marquee; as the user scrolls through 300vh, the capsule grows into a full-bleed image (scale 0.5→1, border-radius 400px→25px) while the marquee fades away, and when the expansion completes the card's title characters slide in through overflow masks (SplitText) together with a small description. Every following card then pins over the previous one — the outgoing card scales down and fades, the incoming card's image de-zooms (scale 2→1) and sharpens its corners (border-radius 150px→25px), and its title/description animate in the same masked-chars way. Smooth scroll via Lenis. An intro section sits above the deck and an outro below.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins ScrollTrigger and SplitText, and lenis (npm) for smooth scrolling. No framework:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";

Register once: gsap.registerPlugin(SplitText, ScrollTrigger);. Wrap all setup in a DOMContentLoaded listener. Put the seamless-marquee helper in a second module (marquee.js) exporting setupMarqueeAnimation().

Layout / HTML

Three blocks in order — intro, cards deck, outro:

<section class="intro">
  <h1>We design spaces that don’t just exist.</h1>
</section>

<section class="cards">
  <div class="card">
    <div class="card-marquee">
      <div class="marquee">
        <h1>Design Beyond Boundaries</h1>
        <h1>Built for Tomorrow</h1>
        <h1>Real Impact</h1>
        <h1>Digital Visions</h1>
      </div>
    </div>
    <div class="card-wrapper">
      <div class="card-content">
        <div class="card-title"><h1>Curved Horizon</h1></div>
        <div class="card-description">
          <p>A futuristic residence that plays with curvature and flow, blending bold geometry with natural topography.</p>
        </div>
      </div>
      <div class="card-img"><img src="…image-1" alt="" /></div>
    </div>
  </div>
  <!-- cards 2–4: identical structure but WITHOUT .card-marquee -->
</section>

<section class="outro">
  <h1>Architecture reimagined for the virtual age.</h1>
</section>
  • Four .cards. Only the first card contains the .card-marquee > .marquee block (four <h1>s). Every card has .card-wrapper > (.card-content + .card-img > img), and .card-content holds .card-title > h1 plus .card-description > p.
  • Card titles / descriptions (neutral fictional architecture copy):
  • Curved Horizon — "A futuristic residence that plays with curvature and flow, blending bold geometry with natural topography."
  • Glass Haven — "A sleek pavilion of pure transparency, openness and light, designed to dissolve into its environment."
  • Moss Cube — "A minimalist cube home crowned with a living moss dome, merging micro-architecture with ecological design."
  • Floating Shelter — "This design explores an ethereal structure perched on a grassy islet, seemingly hovering above water."

Styling

Font: Inter (Google Fonts variable import), body { font-family: "Inter", sans-serif; }. Dark editorial look — every section is #0f0f0f background with #fff text.

  • Reset: * { margin:0; padding:0; box-sizing:border-box; }
  • img { position:relative; width:100%; height:100%; object-fit:cover; will-change:transform; }
  • h1 { font-size:5rem; font-weight:500; letter-spacing:-0.1rem; line-height:1.25; }
  • p { font-size:1.125rem; font-weight:400; line-height:1.25; }
  • section { position:relative; width:100vw; background-color:#0f0f0f; color:#fff; }
  • .intro, .outro { height:100svh; padding:1.5em; display:flex; justify-content:center; align-items:center; } — their h1 is width:60%; text-align:center; line-height:1.1;
  • .cards { position:relative; display:flex; flex-direction:column; gap:25svh; } — the gap between cards is part of the scroll choreography.
  • .card { position:relative; width:100vw; height:100svh; padding:1.5em; }
  • .card:nth-child(2) { margin-top:50vh; } — extra breathing room after the intro capsule card.
  • .card-wrapper { position:relative; width:100%; height:100%; will-change:transform; } (this is what scales/fades out)
  • .card-img { position:absolute; width:100%; height:100%; border-radius:150px; overflow:hidden; }
  • .card-img img { transform:scale(2); }CSS default zoom for the non-intro cards; the intro card's values are overridden by GSAP.
  • .card-content { position:absolute; width:100%; height:100%; display:flex; align-items:flex-end; justify-content:center; z-index:1; }
  • .card-title { width:100%; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; }
  • .card-description { text-align:center; width:40%; margin-bottom:3em; position:relative; transform:translateX(40px); opacity:0; }starts hidden, shifted 40px right.
  • Marquee (first card only): .card-marquee { width:100%; position:absolute; top:50%; left:0; transform:translateY(-50%); overflow:hidden; }, .card-marquee .marquee { display:flex; }, .card-marquee .marquee h1 { white-space:nowrap; font-size:10vw; font-weight:600; margin-right:30px; }
  • SplitText mask pieces: .char { position:relative; overflow:hidden; display:inline-block; } and .char span { transform:translateX(100%); display:inline-block; will-change:transform; }each character span starts fully off to the right inside its own overflow-hidden char box.
  • Mobile @media (max-width: 900px): h1 { font-size:2rem; letter-spacing:0; }, intro/outro h1 { width:100%; }, .card-description { width:90%; }.

GSAP effect (the important part — be exhaustive)

1. Lenis + ScrollTrigger sync
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
2. SplitText prep on ALL card titles

For every .card-title h1: new SplitText(title, { type: "chars", charsClass: "char", tag: "div" }). Then manually wrap each char's text in an inner span: char.innerHTML = <span>${char.textContent}</span>`. Result: each character is a div.char (overflow hidden) containing a span that CSS pushes to translateX(100%)` — invisible until animated.

3. Intro card initial state (the capsule)

cards = gsap.utils.toArray(".card"), introCard = cards[0]. On the intro card only:

gsap.set(cardImgWrapper /* .card-img */, { scale: 0.5, borderRadius: "400px" });
gsap.set(cardImg /* .card-img img */, { scale: 1.5 });
4. Shared content in/out helpers
  • animateContentIn(titleChars, description):
  • chars (.char span NodeList) → gsap.to(chars, { x: "0%", duration: 0.75, ease: "power4.out" })
  • description → gsap.to(desc, { x: 0, opacity: 1, duration: 0.75, delay: 0.1, ease: "power4.out" })
  • animateContentOut(titleChars, description):
  • chars → gsap.to(chars, { x: "100%", duration: 0.5, ease: "power4.out" })
  • description → gsap.to(desc, { x: "40px", opacity: 0, duration: 0.5, ease: "power4.out" })

No stagger — all chars slide together as a masked block.

5. Intro capsule expansion — ScrollTrigger with manual scrub via onUpdate
ScrollTrigger.create({
  trigger: introCard,
  start: "top top",
  end: "+=300vh",
  onUpdate: (self) => { … }
});

Inside onUpdate (no scrub property — everything is driven by self.progress with gsap.set):

  • imgScale = 0.5 + progress * 0.5 → wrapper scale 0.5 → 1
  • borderRadius = 400 - progress * 375400px → 25px (applied as a px string)
  • innerImgScale = 1.5 - progress * 0.5 → inner <img> scale 1.5 → 1 (counter-zoom so the photo stays framed while the mask grows)
  • Marquee fade, keyed to imgScale: while 0.5 ≤ imgScale ≤ 0.75, opacity = 1 - (imgScale - 0.5) / 0.25 (fades 1→0 during the first half of the expansion); clamp to 1 below 0.5 and 0 above 0.75. Applied with gsap.set(marquee, { opacity }) on the first card's .marquee.
  • Content reveal flag: keep a boolean on the card (e.g. introCard.contentRevealed). When progress >= 1 and it's false → set it true and call animateContentIn with the intro card's .char span spans and .card-description. When progress < 1 and it's true → set false and call animateContentOut. This makes the title/description pop in exactly when the capsule finishes expanding, and retract if the user scrolls back.
6. Pinning the deck

Each card gets its own pin trigger:

cards.forEach((card, index) => {
  const isLastCard = index === cards.length - 1;
  ScrollTrigger.create({
    trigger: card,
    start: "top top",
    end: isLastCard ? "+=100vh" : "top top",
    endTrigger: isLastCard ? null : cards[cards.length - 1],
    pin: true,
    pinSpacing: isLastCard,
  });
});

So cards 1–3 pin from their own top top until the LAST card's top hits the top (endTrigger = last card, pinSpacing: false so they stack under each other without adding scroll length), and the last card pins for an extra +=100vh with pinSpacing.

7. Outgoing card scale/fade

For every card except the last: a trigger watching the next card's approach —

ScrollTrigger.create({
  trigger: cards[index + 1],
  start: "top bottom",
  end: "top top",
  onUpdate: (self) => {
    gsap.set(cardWrapper /* this card's .card-wrapper */, {
      scale: 1 - self.progress * 0.25,   // 1 → 0.75
      opacity: 1 - self.progress,        // 1 → 0
    });
  },
});
8. Incoming image morph

For every card except the first: as the card itself travels from bottom of viewport to top —

ScrollTrigger.create({
  trigger: card,
  start: "top bottom",
  end: "top top",
  onUpdate: (self) => {
    gsap.set(cardImg, { scale: 2 - self.progress });                      // img zoom 2 → 1
    gsap.set(imgContainer, { borderRadius: 150 - self.progress * 125 + "px" }); // 150px → 25px
  },
});
9. Content reveal for cards 2–4

For every card except the first:

ScrollTrigger.create({
  trigger: card,
  start: "top top",
  onEnter: () => animateContentIn(cardTitleChars, cardDescription),
  onLeaveBack: () => animateContentOut(cardTitleChars, cardDescription),
});
10. Infinite horizontal marquee (marquee.js)

setupMarqueeAnimation() collects gsap.utils.toArray(".marquee h1") and, if any, runs the canonical GSAP horizontalLoop helper (from GSAP's official helper functions) with { repeat: -1, paddingRight: 30 }:

  • builds a gsap.timeline({ repeat: -1, defaults: { ease: "none" } })
  • speed defaults to 1 → 100 pixels per second, linear
  • measures each item's width and current xPercent, computes totalWidth (last item's offset + width + the 30px paddingRight), then for each item chains a .to (slide left to ((curX - distanceToLoop) / width) * 100 xPercent, duration distanceToLoop / pixelsPerSecond, inserted at time 0) followed by a .fromTo (re-enter from the right at ((curX - distanceToLoop + totalWidth) / width) * 100 back to its start xPercent, immediateRender: false, inserted at distanceToLoop / pixelsPerSecond) — producing a seamless, gapless infinite loop of the four headlines
  • finishes with tl.progress(1, true).progress(0, true) to pre-render positions.

Call setupMarqueeAnimation() at the end of the DOMContentLoaded setup.

Assets / images

Four full-bleed architecture/landscape photos (landscape orientation, ~16:9 or wider works best; they render full-viewport with object-fit: cover):

  1. A futuristic white residence with curved, flowing organic geometry set into natural terrain (the capsule/intro card).
  2. A sleek transparent glass pavilion full of light, dissolving into its surroundings.
  3. A minimalist concrete cube house topped with a living green moss dome.
  4. An ethereal structure on a small grassy islet that appears to float above water.

Behavior notes

  • The whole page scroll is the interaction — no clicks/hovers. Total scroll ≈ intro (100svh) + capsule expansion (300vh) + the stacked deck + outro.
  • Scrolling back up fully reverses everything: capsule shrinks, marquee fades back in, titles retract through their masks (onLeaveBack / the contentRevealed flag).
  • The marquee loop runs forever, independent of scroll.
  • will-change: transform on images, .card-wrapper and .char span keeps the constant gsap.set updates smooth.
  • Works on mobile via the 900px media query (smaller type, full-width text); no reduced-motion branch is required.