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.