All components

Capsules Animated Sticky Columns

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

Open live demo ↗ Raw prompt (.md)

What it does

A pinned full-screen section where four rounded 'capsule' columns swap in place as you scroll. A single ScrollTrigger pins the section over 5x viewport height (Lenis-smoothed) while a second ScrollTrigger watches progress and, at the 0.33 and 0.66 thresholds, fires phased gsap.to tweens that slide columns in from X/Y offsets, fade/scale the previous column out, grow a clip-path reveal on an image, and swap SplitText line spans between two stacked text blocks. Scrolling back reverses each phase.

How it's built

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

scroll pin sticky-columns clip-path splittext lenis phased-transition editorial minimal

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

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:

  1. <section class="intro"> — a single centered <h1> (e.g. "We create modern interiors that feel effortlessly personal.").
  2. <section class="sticky-cols"> — the pinned section. Inside it a <div class="sticky-cols-wrapper"> containing four absolutely-positioned columns:
  3. <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).
  4. <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).
  5. <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>).
  6. <div class="col col-4"><div class="col-img"><div class="col-img-wrapper"><img> (image 3).
  7. <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 to body. 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 their h1 is width: 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-1 has 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-wrapper also gets padding: 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 to 2rem.

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):

  1. Image 1 — first image capsule in col-2, visible from the start.
  2. Image 2 — second image capsule in col-2, hidden by the zero-height clip-path and revealed in phase 1.
  3. 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.