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 > .marqueeblock (four<h1>s). Every card has.card-wrapper > (.card-content + .card-img > img), and.card-contentholds.card-title > h1plus.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; }— theirh1iswidth: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/outroh1 { 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 spanNodeList) →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 → 1borderRadius = 400 - progress * 375→ 400px → 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: while0.5 ≤ imgScale ≤ 0.75,opacity = 1 - (imgScale - 0.5) / 0.25(fades 1→0 during the first half of the expansion); clamp to1below 0.5 and0above 0.75. Applied withgsap.set(marquee, { opacity })on the first card's.marquee. - Content reveal flag: keep a boolean on the card (e.g.
introCard.contentRevealed). Whenprogress >= 1and it's false → set it true and callanimateContentInwith the intro card's.char spanspans and.card-description. Whenprogress < 1and it's true → set false and callanimateContentOut. 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, computestotalWidth(last item's offset + width + the 30pxpaddingRight), then for each item chains a.to(slide left to((curX - distanceToLoop) / width) * 100xPercent, durationdistanceToLoop / pixelsPerSecond, inserted at time 0) followed by a.fromTo(re-enter from the right at((curX - distanceToLoop + totalWidth) / width) * 100back to its start xPercent,immediateRender: false, inserted atdistanceToLoop / 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):
- A futuristic white residence with curved, flowing organic geometry set into natural terrain (the capsule/intro card).
- A sleek transparent glass pavilion full of light, dissolving into its surroundings.
- A minimalist concrete cube house topped with a living green moss dome.
- 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/ thecontentRevealedflag). - The marquee loop runs forever, independent of scroll.
will-change: transformon images,.card-wrapperand.char spankeeps the constantgsap.setupdates 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:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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.