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.

Images

This component ships with 4 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/capsules-sticky-cards-javascript/card-img-1.jpg
https://motionprompts.dev/c/capsules-sticky-cards-javascript/card-img-2.jpg
https://motionprompts.dev/c/capsules-sticky-cards-javascript/card-img-3.jpg
https://motionprompts.dev/c/capsules-sticky-cards-javascript/card-img-4.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--peat, --peat-deep, --parchment, --parchment-dim, --moss, --line. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.

Adapting this to React

Everything above describes a standalone document: one script that waits for DOMContentLoaded, builds its card list off a bare gsap.utils.toArray(".card"), reaches into the page through introCard.querySelector(...) and a dozen more unscoped lookups, wires up fourteen independent ScrollTrigger.create() calls — the capsule-expansion scrub, four pin triggers, three outgoing-card fades, three incoming-image morphs, three content-reveal triggers — and never has to undo any of it, because the page it lives on never unmounts. React withdraws that last guarantee first, and it does it quietly: the capsule still expands and the deck still stacks on first load, and the damage only shows up on a second mount or a real navigation away.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This component accumulates an unusual amount of independent live state before that first unmount can land: one Lenis instance pumped by a gsap.ticker callback, fourteen ScrollTriggers (four of them pinning), four SplitText instances (one per .card-title h1), an infinite marquee timeline, and a contentRevealed flag written directly onto the intro card's own DOM node. Mount it twice without tearing the first pass down and every pin doubles — two triggers pinning the same card, disagreeing about the same scrub — the marquee opacity and the capsule's scale and border-radius get written by two competing onUpdate handlers, and Lenis's scroll gets driven by two ticker callbacks at once. None of it reproduces in a production build, because React only double-invokes in development; treat the cleanup below as load-bearing.

*(1) The entry point* — the whole effect is wrapped in document.addEventListener("DOMContentLoaded", ...). By the time a React component mounts, that event has already fired, so the listener is registered and never called: no capsule, no split characters, no pins, nothing to debug because nothing throws. Delete the listener and move its entire body — plugin registration, the Lenis/ticker wiring, the SplitText prep loop, the capsule's initial gsap.set, the content-in/out helpers, all fourteen ScrollTrigger.create() calls, and the closing setupMarqueeAnimation() call — directly into a useEffect with an empty dependency array. While you're moving this, hoist gsap.registerPlugin(SplitText, ScrollTrigger) and gsap.ticker.lagSmoothing(0) out to module scope: both currently sit inside the DOMContentLoaded callback, but they're one-time, app-wide ticker configuration, not per-mount state, and re-running them on every mount is harmless only by luck, not by design.

*(2) Element lookups* — gsap.utils.toArray(".card") and gsap.utils.toArray(".card-title h1") are both unscoped, and everything else — introCard.querySelector(".card-img"), .card-img img, .card-marquee .marquee, .char span, .card-description, and the same set repeated per card across the four forEach loops — is scoped only relative to the elements those two calls already found. Give the component a root ref on the element wrapping .cards, and scope both toArray calls to it; every downstream .querySelector already scopes off introCard or card, so fixing the two roots fixes the rest. The marquee helper has the same gap one level removed: setupMarqueeAnimation() in marquee.js calls its own unscoped gsap.utils.toArray(".marquee h1"), so pass it the root (or the marquee element itself) instead of leaving it to search the whole document. During the StrictMode remount two copies of the deck exist for an instant — an unscoped toArray(".card") can bind to the four cards on their way out, and every pin, scrub and reveal for the rest of that mount's life targets elements no longer in the visible tree.

*(3) Cleanup* — Wrap the whole body in a gsap.context scoped to the root ref:

useEffect(() => {
  const ctx = gsap.context(() => {
    // SplitText prep, capsule initial state, the content-in/out helpers,
    // all fourteen ScrollTrigger.create() calls, setupMarqueeAnimation()
  }, rootRef);
  return () => ctx.revert();
}, []);

ctx.revert() kills every ScrollTrigger this component creates, including the four pin triggers — un-pinning each card and, for the last one (the only one carrying pinSpacing), collapsing the extra scroll length it added. It also correctly tears down the marquee's infinite timeline, even though setupMarqueeAnimation() never returns or exposes it: that timeline is built synchronously, at the tail end of the same factory call, so gsap.context captures it the same way it captures everything else created while the factory is running.

What it does *not* reach is the gsap.set traffic inside the capsule scrub, the outgoing-fade and the incoming-morph triggers — none of those attach an animation, they all drive their scrub by calling gsap.set from inside their own onUpdate. A gsap.context only captures objects instantiated while its factory is synchronously executing; onUpdate fires later, on an actual scroll event, well after that synchronous pass has returned, so none of those calls are ever tracked. Reverting stops the *next* one from firing — it does not restore whatever transform, borderRadius or opacity the last one already wrote to cardImgWrapper, cardImg, the marquee, or a given card's .card-wrapper. A fresh mount's own triggers overwrite those stale values on their first tick, but only once GSAP re-evaluates them against the current scroll position — there is a real gap between revert and that first re-evaluation, not just a theoretical one.

The ticker is the sharper miss. gsap.ticker.add((time) => lenis.raf(time * 1000)) is neither a tween nor a trigger, so the context never sees it — and this component has no requestAnimationFrame loop of its own to fold that concern into, because the ticker callback *is* the entire drive mechanism for Lenis's frame pump. Keep the function reference and remove it explicitly, before destroying Lenis, or a tick landing between the two calls invokes .raf() on an instance you've already torn down:

const onTick = (time) => lenis.raf(time * 1000);
gsap.ticker.add(onTick);
// cleanup, in this order:
gsap.ticker.remove(onTick);
lenis.destroy();
ctx.revert();

lenis.on("scroll", ScrollTrigger.update) doesn't need the same treatment — that listener lives on the Lenis instance's own emitter, so lenis.destroy() clears it as a side effect of tearing the instance down. Lenis itself is a document-level resource: if this deck is one section of a larger app, lift the new Lenis() call to the app shell and have this effect reuse the existing instance instead of constructing a second one that fights the first for the same wheel event.

The four SplitText instances need the same explicit handling, one per .card-title h1, created in the titles.forEach loop. Collect them into an array as you create them and revert every one in the cleanup, ordered before ctx.revert() runs against the tweens that target their .char spans — otherwise a remount re-splits already-split markup and nests a second layer of .char divs inside the first. The char.innerHTML = "<span>...</span>" line right after each split needs no separate teardown: it writes inside the container the split itself owns, so one revert() call per title discards the manual span wrap along with the character division.

One more piece of state lives outside both GSAP and React: introCard.contentRevealed, a plain boolean written directly onto the card's DOM node and read back by the capsule trigger's onUpdate to decide between firing animateContentIn or animateContentOut. Neither gsap.context nor SplitText's revert touches it, and because StrictMode's double-invoke reuses the same committed DOM node across both effect runs, the flag carries over unchanged — a second mount can inherit "already revealed" from the run before it ran at all. Reset it explicitly at the top of the effect, or replace the DOM property with a variable local to the effect's own closure, so every mount starts from a known state instead of whatever the previous one left behind.