All components

Zentry Scroll Animations

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

Open live demo ↗ Raw prompt (.md)

What it does

A long scroll story where a section is pinned for 4x the viewport height and every stage is scrubbed by scroll. As you scroll, letters of the intro copy flicker in with a random stagger, a center image expands from a small rectangle to fullscreen via an animated clip-path while its image scales, then a third image opens from a vertical seam and zooms far in before zooming back out, and finally a rotateY(-75deg) headline swings flat to reveal the closing copy. All driven by GSAP ScrollTrigger (pin + scrub) with Lenis smooth scrolling.

How it's built

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

scroll pin scrub clip-path image-reveal text-flicker 3d-perspective lenis cinematic

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

Cosmic Scroll Story — Pinned Section with Clip-Path Reveals, Deep Zoom & Flip-Open Headline

Goal

Build a long-scroll "scrollytelling" page whose centerpiece is a single section pinned for 4 viewport heights while every stage is scrubbed by scroll. As you scroll through the pinned section: the letters of two intro paragraphs flicker in one-by-one in a random order; a small centered rectangle expands via an animated clip-path into a fullscreen image (its photo scaling up at the same time); then a third image opens outward from a central seam while it is held at a heavy 3× zoom, and finally zooms back out to natural scale; and as a closing beat a headline that starts folded away at rotateY(-75deg) swings flat to camera to reveal the last line of copy. All of it is driven by GSAP ScrollTrigger (one pin + many scrubbed tweens) with Lenis smooth scroll.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scroll. Register with gsap.registerPlugin(ScrollTrigger). Run everything inside a DOMContentLoaded handler. No SplitText, no CustomEase, no Three.js — the letter split is done by hand.

Layout / HTML

Single .container wrapping five full-viewport sections in order. Class names are load-bearing (JS/CSS query them):

<div class="container">
  <section class="hero">
    <h1>Genesis</h1>
    <p>At the nexus of infinite realities, the Aethoria Spire rises: a beacon of limitless potential in the cosmic tapestry.</p>
  </section>

  <section class="about">
    <div class="about-img"><img src="..." alt="" /></div>
    <div class="about-copy"><h1>The Omniversal Fulcrum</h1></div>
  </section>

  <section class="sticky">
    <div class="intro">
      <div class="intro-col">
        <p>Veiled depths of eternity</p>
        <p>Currents of cosmic wisdom</p>
      </div>
      <div class="intro-col">
        <p>Nexus of all existence</p>
      </div>
    </div>
    <div class="img-1"><img src="..." alt="" /></div>
    <div class="img-2"><img src="..." alt="" /></div>
    <div class="img-3"><img src="..." alt="" /></div>
    <div class="copy">
      <h1>The cosmic keystone within Aethoria unlocks the gates to multiversal transformation</h1>
    </div>
  </section>

  <section class="footer">
    <h1>Infinite Realms <br /> Beckon Beyond</h1>
  </section>
</div>

All copy is fictional cosmic flavor text — keep it neutral, no brand names.

Styling

Fonts: body/paragraphs use Roboto Mono (Google Fonts, weights 100–700). Every h1 uses a heavy condensed uppercase display face — declare font-family: "FK Screamer" with a robust fallback (e.g. a bold grotesque like "Anton", "Archivo Black", sans-serif); it must render as a massive, tightly-set, all-caps headline.

Global reset & base:

  • * { margin:0; padding:0; box-sizing:border-box }.
  • html, body { width:100%; height:700vh !important; font-family:"Roboto Mono", monospace } — the 700vh body height is essential: it is the total scroll runway that makes the pin + all scrubbed ranges reachable.
  • img { width:100%; height:100%; object-fit:cover }.
  • h1 { text-transform:uppercase; font-size:15vw; font-weight:600; line-height:90%; letter-spacing:2px; color:#fff }.
  • p { text-transform:uppercase; font-size:14px; font-weight:400; color:#fff }.
  • section { width:100vw; height:100vh }.

Sections:

  • .hero: padding:4em; display:flex; flex-direction:column; justify-content:space-between; align-items:center; full-bleed background image background: url(...) no-repeat 50% 50%; background-size:cover. .hero p { width:50%; text-align:center }.
  • .about: padding:4em 12em; display:flex; justify-content:center; align-items:center; gap:10em; background-color:#667e74 (muted sage green). .about-img, .about-copy { flex:1 }. .about-img { height:75%; border:2px solid #000 }. .about-copy h1 { text-align:center; font-size:10vw; color:#263a30 } (dark green, overrides the white).
  • .footer: padding:2em; display:flex; justify-content:center; align-items:center; text-align:center; background-color:#1d2944 (dark navy).
  • .sticky: position:relative; perspective:1000px — the perspective:1000px is required for the closing headline's 3D rotateY swing to read as depth.

Sticky-section children (all layered inside .sticky):

  • .intro { position:absolute; top:50%; transform:translateY(-50%); width:100%; padding:1em; display:flex; z-index:2 } — sits above the images. .intro-col { flex:1; display:flex }, .intro-col p { flex:1 }, .intro-col p span { display:inline-block }. Second column right-aligned: .intro-col:nth-child(2) p { text-align:right }.
  • .img-1, .img-2, .img-3 { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden } — three stacked full-cover layers (img-1 bottom, img-3 top).
  • .img-2 initial clip-path: polygon(40% 25%, 60% 25%, 60% 75%, 40% 75%) — a small centered rectangle (20% wide × 50% tall).
  • .img-3 initial clip-path: polygon(50% 25%, 50% 25%, 50% 75%, 50% 75%) — a zero-width vertical seam down the center. .img-3 img { transform-origin:top right; transform:scale(3) } — the top image starts zoomed to 3×, anchored to its top-right.
  • .copy { width:50%; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) rotateY(-75deg) scale(0.5); transform-origin:bottom left; display:none } — the closing headline starts hidden, folded 75° away and half-scale. .copy h1 { font-size:5vw; text-align:center }.

Responsive (max-width:900px): h1font-size:20vw; .hero pwidth:100%; .about stacks column (flex-direction:column; gap:4em; padding:4em 2em), .about-copy { flex:0.5 }, .about-img { margin-top:4em; height:100% }; .intro and .intro-col become flex-direction:column, .intro-col p { text-align:center !important }; .copy { width:90% }, .copy h1 { font-size:10vw }.

GSAP effect (be exact)

Lenis + ScrollTrigger wiring
const stickySection = document.querySelector(".sticky");
const totalStickyHeight = window.innerHeight * 4;

const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);

Lenis drives scroll; GSAP's ticker drives Lenis (seconds → ms via * 1000). totalStickyHeight (4 × viewport) is the pin length and the reference for every absolute scroll marker below.

1. Manual letter split (no SplitText)

For each .intro-col p, take its textContent, split on whitespace (keep the spaces), and for every non-space chunk wrap each character in <span style="opacity:0; display:inline-block;">…</span>. Reassemble as innerHTML. Result: every letter is an individually-addressable, initially-invisible inline-block span; spaces stay as plain text.

2. Flicker-in / flicker-out of the intro letters

A reusable helper animates all letter spans to a target opacity with a random-order flicker:

function flickerAnimation(targets, toOpacity) {
  gsap.to(targets, {
    opacity: toOpacity,
    duration: 0.05,
    stagger: { amount: 0.3, from: "random" },
  });
}
  • duration:0.05 per letter (near-instant snap), stagger.amount:0.3 spreads the whole batch across 0.3s total, from:"random" scatters the order → a flickering, non-linear appearance.

Fire it from a callback-only ScrollTrigger (not scrubbed):

ScrollTrigger.create({
  trigger: stickySection,
  start: "top top",
  end: () => `${window.innerHeight * 3}`,   // absolute scroll position (px), NOT "+="
  onEnter:     () => flickerAnimation(".intro-col p span", 1),
  onLeave:     () => flickerAnimation(".intro-col p span", 0),
  onEnterBack: () => flickerAnimation(".intro-col p span", 1),
  onLeaveBack: () => flickerAnimation(".intro-col p span", 0),
});

So the letters flicker on when the pin begins and flicker off near the end of the third viewport of scroll; scrolling back reverses it. (Because end is a bare number string it is treated as an absolute page-scroll position of 3 × innerHeightpx, not a relative offset.)

3. Pin the sticky section
ScrollTrigger.create({
  trigger: stickySection,
  start: "top top",
  end: () => `+=${totalStickyHeight}`,   // +=4×innerHeight
  pin: true,
  pinSpacing: true,
});

The section locks to the viewport for 4 viewport-heights of scrolling; pinSpacing:true inserts that runway so the footer follows afterward.

4. img-2 reveal — clip-path expand + photo scale (first viewport, scroll 0 → 1×vh)

Two scrubbed tweens over start:"top top", end:() => "+="+window.innerHeight, scrub:true, ease:"none":

(a) clip-path driven manually in onUpdate by interpolating the small centered rectangle out to the full frame:

scrollTrigger: {
  trigger: stickySection, start: "top top",
  end: () => `+=${window.innerHeight}`, scrub: true,
  onUpdate: (self) => {
    const p = self.progress;
    gsap.set(".img-2", { clipPath: `polygon(
      ${gsap.utils.interpolate(40, 0, p)}% ${gsap.utils.interpolate(25, 0, p)}%,
      ${gsap.utils.interpolate(60, 100, p)}% ${gsap.utils.interpolate(25, 0, p)}%,
      ${gsap.utils.interpolate(60, 100, p)}% ${gsap.utils.interpolate(75, 100, p)}%,
      ${gsap.utils.interpolate(40, 0, p)}% ${gsap.utils.interpolate(75, 100, p)}%
    )` });
  },
}

Corner journeys: left x 40→0, right x 60→100, top y 25→0, bottom y 75→100. The centered rectangle grows outward on all four edges into polygon(0 0,100 0,100 100,0 100) — a rectangular iris opening to fullscreen. (The tween's own clipPath target is that full polygon; the onUpdate overrides each frame so the interpolation is explicit.)

(b) .img-2 img scale → 1.125 over the same range (a slow inward push while the frame opens).

5. img-3 open + hold-zoom (fourth viewport, scroll 3×vh → 4×vh)

These use absolute scroll markers start:() => window.innerHeight*3, end:() => window.innerHeight*4, scrub:true, ease:"none".

(a) clip-path opens from the center outward, again interpolated in onUpdate:

onUpdate: (self) => {
  const p = self.progress;
  gsap.set(".img-3", { clipPath: `polygon(
    ${gsap.utils.interpolate(50, 0, p)}% ${gsap.utils.interpolate(50, 0, p)}%,
    ${gsap.utils.interpolate(50, 100, p)}% ${gsap.utils.interpolate(50, 0, p)}%,
    ${gsap.utils.interpolate(50, 100, p)}% ${gsap.utils.interpolate(50, 100, p)}%,
    ${gsap.utils.interpolate(50, 0, p)}% ${gsap.utils.interpolate(50, 100, p)}%
  )` });
}

All four corners start at the center point 50% 50% and expand to the frame corners → the top image irises open from the middle to fullscreen. (Target clipPath is the full polygon.)

(b) .img-2 img continues scaling fromTo(1.125 → 1.25) over this same 3×→4× range (keeps the middle layer creeping in).

(c) .img-3 img scale to: 2.9 over 3×→4× — it is essentially held at its heavy 3× zoom (CSS start scale is 3, animating to 2.9), so as img-3 opens you see a deep, tightly-cropped detail.

6. img-3 zoom-out (scroll 4×vh → 6×vh)
gsap.fromTo(".img-3 img", { scale: 2.9 }, {
  scale: 1, ease: "none",
  scrollTrigger: {
    trigger: stickySection,
    start: () => `${window.innerHeight * 4}`,
    end:   () => `${window.innerHeight * 6}`,
    scrub: true,
  },
});

Over the next two viewports the top image pulls back from 2.9× to its natural 1× — the "reveal the whole picture" beat. (This range extends past the 4×vh pin release, so the zoom-out finishes as the section scrolls away.)

7. Closing headline flip-open (scroll 4.5×vh → 5.5×vh)
const tl = gsap.timeline({
  scrollTrigger: {
    trigger: stickySection,
    start: () => `${window.innerHeight * 4.5}`,
    end:   () => `${window.innerHeight * 5.5}`,
    scrub: true,
    toggleActions: "play reverse play reverse",
  },
});
tl.to(".copy", { display: "block", rotateY: 0, scale: 1, duration: 1 });

.copy (hidden, rotateY(-75deg) scale(0.5), transform-origin:bottom left) swings flat to rotateY(0) scale(1) and becomes visible — with the parent perspective:1000px, it reads as a panel hinging open toward the viewer to present the final line. Scrubbed and reversible.

Timing summary (in units of window.innerHeight of scroll)
  • 0 → 1: intro letters flickered in (from pin start); img-2 clip-path iris opens to fullscreen; img-2 photo scales 1→1.125.
  • 1 → 3: pinned, mostly holding (letters still on).
  • ~3: intro letters flicker out (flicker trigger ends at 3).
  • 3 → 4: img-3 clip-path irises open from center; img-3 photo held ~3× (3→2.9); img-2 photo continues 1.125→1.25.
  • 4 → 6: img-3 photo zooms out 2.9 → 1 (pin releases at 4).
  • 4.5 → 5.5: closing .copy headline flips from rotateY(-75deg) scale(0.5) to flat.

All scrubbed tweens use ease:"none" so motion is linearly tied to scroll; the only non-scrubbed animation is the flicker (its own short easing/stagger).

Assets / images

Five images, all photographic and atmospheric — a matched cinematic/cosmic set (nebulae, celestial landscapes, monolithic sci-fi structures, deep-space vistas), no logos or text:

  • 1 hero background — full-bleed landscape (≈16:9), the opening cosmic backdrop behind the "Genesis" title.
  • 1 about image — portrait-ish framed photo (roughly 3:4), sits inside a 2px black border beside the green section's heading.
  • 3 fullscreen sticky images — each fills a 100vw × 100vh layer with object-fit:cover, so use large landscape-or-square images (≈16:9). img-1 is the bottom backdrop; img-2 is the rectangle that irises open; img-3 is the top layer that opens from the seam and holds a deep zoom, so it benefits from an image with strong central detail that rewards the 3× crop.

Behavior notes

  • The page hijacks scroll via Lenis and pins a full section for 4 viewport-heights; it is a page-level, immersive experience (desktop-first but the max-width:900px query reflows it for mobile).
  • Everything except the letter flicker is fully scrubbed and reversible — scrolling back plays every reveal, zoom and flip backwards.
  • The 700vh body height, the pin length (4×innerHeight), and the absolute scroll markers (, , 4.5×, 5.5×, innerHeight) must line up — they're what sequence the beats; recompute them from window.innerHeight (the demo reads it at load).
  • Light performance cost (no canvas/WebGL); the clip-path morphs are the heaviest part.