Capsules Animated Sticky Columns — scroll-pinned phased column swap
Goal
Build a full-page scroll experience with a pinned full-screen section containing four rounded "capsule" columns (two text capsules, two image capsules). As the user scrolls through the pinned section, the columns swap in two discrete phases: new capsules slide in from the right/bottom while the previous one fades and scales away, an image is revealed with a growing clip-path wipe, and a text block swaps via SplitText line masks. Scrolling back up reverses each phase.
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. Everything runs inside a DOMContentLoaded listener. Register plugins with gsap.registerPlugin(ScrollTrigger, SplitText).
Lenis wiring (exact pattern):
const lenis = new Lenis()(default options).lenis.on("scroll", ScrollTrigger.update).gsap.ticker.add((time) => lenis.raf(time * 1000)).gsap.ticker.lagSmoothing(0).
Layout / HTML
Three sections, each 100vw × 100svh:
<section class="intro">— a single centered<h1>(e.g. "We create modern interiors that feel effortlessly personal.").<section class="sticky-cols">— the pinned section. Inside it a<div class="sticky-cols-wrapper">containing four absolutely-positioned columns:<div class="col col-1">→<div class="col-content">→<div class="col-content-wrapper">with an<h1>(e.g. "We design spaces where comfort meets quiet sophistication.") and a<p>(a two-sentence supporting paragraph about layered textures and thoughtful details).<div class="col col-2">→ two stacked image capsules:<div class="col-img col-img-1">and<div class="col-img col-img-2">, each containing<div class="col-img-wrapper">→<img>(image 1 and image 2).<div class="col col-3">→ two stacked text blocks:<div class="col-content-wrapper">(an<h1>like "Our interiors are crafted to feel as calm as they look." + a supporting<p>) and<div class="col-content-wrapper-2">(an<h1>like "Every detail is chosen to bring ease and elegance into your space." + a supporting<p>).<div class="col col-4">→<div class="col-img">→<div class="col-img-wrapper">→<img>(image 3).<section class="outro">— a single centered<h1>(e.g. "Timeless design begins with a conversation.").
Styling
- Google Font "DM Sans" (import via Google Fonts
@import), applied tobody. Sans-serif fallback. - CSS variables:
--bg: #141414(page/section background),--bg-200: #282828(capsule background),--fg: #fff,--fg-100: #f1f1f1,--fg-200: #a1a1a1. - Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. section { position: relative; width: 100vw; height: 100svh; background: var(--bg); color: var(--fg-100); overflow: hidden; }.- Type:
h1 { font-size: 2.5rem; font-weight: 500; line-height: 1.1; },p { font-size: 1rem; font-weight: 500; }. Intro/outro sections are flex-centered and theirh1iswidth: 50%; text-align: center;. img { width: 100%; height: 100%; object-fit: cover; }..sticky-cols { padding: 0.5rem; },.sticky-cols-wrapper { position: relative; width: 100%; height: 100%; }..col { position: absolute; width: 50%; height: 100%; will-change: transform; }. Inside columns:h1 { color: var(--fg-200); width: 60%; },p { color: var(--fg-100); width: 60%; }.- Initial offsets (plain CSS transforms — these are the "off-stage" positions):
.col-2 { transform: translateX(100%); }(right half, on screen)..col-3 { transform: translateX(100%) translateY(100%); padding: 0.5rem; }(off-screen bottom-right)..col-4 { transform: translateX(100%) translateY(100%); }(off-screen bottom-right)..col-1has no offset (left half, on screen).- Capsule look:
.col-content, .col-img { position: relative; width: 100%; height: 100%; padding: 0.5rem; };.col-content-wrapper, .col-img-wrapper { position: relative; width: 100%; height: 100%; background: var(--bg-200); border-radius: 3rem; overflow: hidden; }..col-content-wrapperalso getspadding: 2.5rem; display: flex; flex-direction: column; justify-content: space-between;(h1 at top, p at bottom). .col-content-wrapper-2 { position: absolute; top: 0; left: 0; width: 100%; height: 100%; padding: 2.5rem; display: flex; flex-direction: column; justify-content: space-between; }— it sits exactly on top of col-3's first wrapper (note: no background of its own).- Stacked images in col-2:
.col-img-1, .col-img-2 { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }. .col-img-2 { clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%); }— a zero-height sliver at the top, so image 2 starts fully hidden..col-img-2 img { scale: 1.25; }— image 2 starts zoomed in.- Line-mask helpers for SplitText:
.line { overflow: hidden; }and.line span { display: block; will-change: transform; }. - Media query
@media (max-width: 1000px):h1 { font-size: 1.25rem; },p { font-size: 0.85rem; },.col h1, .col p { width: 100%; }, wrappers' padding drops to2rem.
GSAP effect (the core — follow exactly)
1. SplitText setup (col-3 text swap)
On init, split every .col-3 h1, .col-3 p element with new SplitText(element, { type: "lines", linesClass: "line" }). Then, for each resulting line element, manually wrap its text in a span: line.innerHTML = <span>${line.textContent}</span>`. Combined with the .line { overflow: hidden }` CSS this creates per-line masks.
Initial states (gsap.set):
.col-3 .col-content-wrapper .line span→{ y: "0%" }(first text block visible)..col-3 .col-content-wrapper-2 .line span→{ y: "-125%" }(second text block hidden above its line masks).
2. ScrollTriggers
Two separate ScrollTriggers on the same trigger element .sticky-cols:
- Pin trigger:
{ trigger: ".sticky-cols", start: "top top", end:+=${window.innerHeight * 5}px, pin: true, pinSpacing: true }. No animation attached — it only pins. - Progress trigger:
{ trigger: ".sticky-cols", start: "top top", end:+=${window.innerHeight * 6}px, onUpdate: (self) => { ... } }. Note the end distance is 6× viewport height (intentionally longer than the pin), so the phase thresholds at 33%/66% of this trigger land inside the pinned range.
3. Phase state machine (inside onUpdate)
Keep a let currentPhase = 0 variable outside the trigger. In onUpdate, read self.progress and fire tweens ONCE per threshold crossing (not scrubbed — these are discrete gsap.to tweens triggered by crossing a progress value):
Phase 0 → 1 (when progress >= 0.33 && currentPhase === 0; set currentPhase = 1). All tweens duration: 0.75, default ease:
.col-1→{ opacity: 0, scale: 0.75 }(left text capsule fades/shrinks away)..col-2→{ x: "0%" }(image column slides from the right half to the left half)..col-3→{ y: "0%" }(text column rises from below into the right half — its X stays at 100% from the CSS transform)..col-img-1 img→{ scale: 1.25 }(first image zooms in)..col-img-2→{ clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)" }(second image wipes open from the top edge to full height, revealing it over image 1)..col-img-2 img→{ scale: 1 }(second image zooms out from 1.25 to 1 while being revealed).
Phase 1 → 2 (when progress >= 0.66 && currentPhase === 1; set currentPhase = 2). All duration: 0.75:
.col-2→{ opacity: 0, scale: 0.75 }(image column fades/shrinks away)..col-3→{ x: "0%" }(text column slides from right half to left half)..col-4→{ y: "0%" }(final image column rises from below into the right half)..col-3 .col-content-wrapper .line span→{ y: "-125%", duration: 0.75 }(first text block's lines exit upward through their masks)..col-3 .col-content-wrapper-2 .line span→{ y: "0%", duration: 0.75, delay: 0.5 }(second text block's lines drop in from above, starting 0.5s later).
Reverse 1 → 0 (when progress < 0.33 && currentPhase >= 1; set currentPhase = 0). Mirror every phase-1 tween back to its start value, all duration: 0.75: .col-1 → { opacity: 1, scale: 1 }, .col-2 → { x: "100%" }, .col-3 → { y: "100%" }, .col-img-1 img → { scale: 1 }, .col-img-2 → { clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)" }, .col-img-2 img → { scale: 1.25 }.
Reverse 2 → 1 (when progress < 0.66 && currentPhase === 2; set currentPhase = 1). All duration: 0.75: .col-2 → { opacity: 1, scale: 1 }, .col-3 → { x: "100%" }, .col-4 → { y: "100%" }, .col-3 .col-content-wrapper .line span → { y: "0%", duration: 0.75, delay: 0.5 } (delay on the incoming block), .col-3 .col-content-wrapper-2 .line span → { y: "-125%", duration: 0.75 }.
No custom eases anywhere — every tween uses GSAP's default ease (power1.out). No timelines; all tweens in a phase start simultaneously (except the noted delay: 0.5).
Assets / images
3 interior-design photographs (warm, softly lit modern rooms), used as full-bleed object-fit: cover fills inside rounded capsules of roughly half the viewport width × full viewport height (portrait-ish crop is safest):
- Image 1 — first image capsule in col-2, visible from the start.
- Image 2 — second image capsule in col-2, hidden by the zero-height clip-path and revealed in phase 1.
- Image 3 — final image capsule in col-4, slides up in phase 2.
No logos or brand marks; keep copy neutral (interior design studio voice).
Behavior notes
- The whole animation is scroll-driven but the tweens themselves are time-based (0.75s), fired at thresholds — it should feel like snappy state changes, not scrubbing.
- Scrolling back up must reverse each phase via the mirror tweens above.
- The intro and outro sections scroll normally before/after the pinned section; pinSpacing keeps the document flow.
- Works at any viewport; below 1000px the type scales down per the media query. No reduced-motion handling required.