All components

VoyeurVerite Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

Pinned hero scrubbed over five viewport heights with four manual phases: the foreground image clips into a central vertical slit while a dark overlay fades in, then rotates to 65deg, scales to zero over sliding background text columns as a red overlay flashes on, and finally two outro images wipe in via clip-path from top and bottom before a SplitText line-masked headline staggers up at 90% progress.

How it's built

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

scroll pin scrub clip-path rotate overlay split-text lenis hero

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

VoyeurVerite Scroll Animation — Pinned Rotating-Slit Hero over Five Viewports

Goal

Build a full-screen editorial hero that is pinned and scrubbed across five viewport heights, driven by a single manual ScrollTrigger.onUpdate handler (NOT a gsap.timeline) that reads self.progress and hand-computes four sequential phases with gsap.utils.clamp + gsap.utils.interpolate. In order, as you scroll: a full-bleed foreground image clips inward to a thin central vertical slit while a dark overlay simultaneously fades in over it (so the slit goes black); the slit then rotates to 65°; then it scales down to zero while, behind it, two background text columns slide apart and a gold accent overlay flash-fills the shrinking slit; then two side-by-side outro images wipe in via clip-path (left one top-down, right one bottom-up); and finally, once scroll passes 90%, a line-masked SplitText headline staggers up with a real (non-scrubbed) tween. Smooth scroll via Lenis. Then a plain dark about section follows.

Tech

Vanilla HTML/CSS/JS with ES module imports (fresh Vite project). Install and import from npm:

  • gsap (3.x) plus the plugins ScrollTrigger and SplitText.
  • lenis — smooth scroll.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger, SplitText);

No framework, no Three.js, no CustomEase. Everything runs at module top level (no DOMContentLoaded wrapper needed since the script is a deferred module).

Layout / HTML

One <section class="hero"> holding three stacked full-cover layers plus a following <section class="about">. Class names are load-bearing — the JS and CSS query them.

<section class="hero">
  <!-- z-index 2, TOP layer: the clipping/rotating/scaling foreground -->
  <div class="hero-fg-content">
    <div class="hero-fg-img">
      <img src="…" alt="" />
    </div>
    <div class="hero-fg-header">
      <h1>Silhouettes against the burning dark</h1>
    </div>
    <div class="hero-fg-overlay-dark"></div>
    <div class="hero-fg-overlay"></div>
  </div>

  <!-- z-index 0, BOTTOM layer: two text columns revealed behind the slit -->
  <div class="hero-bg-content">
    <div class="hero-bg-content-col">
      <div class="hero-bg-content-copy">
        <h3>Motion</h3>
        <p>Bodies drawn through engineered light and open dark. Every frame caught between the signal and the shadow that it quietly leaves behind.</p>
      </div>
    </div>
    <div class="hero-bg-content-col">
      <div class="hero-bg-content-copy">
        <h3>Silence</h3>
        <p>Stillness measured in reflected color and slow heat. Where the moving crowd dissolves and only the burning outline holds against the night.</p>
      </div>
    </div>
  </div>

  <!-- z-index 1, MIDDLE layer: two outro images + centered headline -->
  <div class="hero-outro-content">
    <div class="hero-outro-img"><img src="…" alt="" /></div>
    <div class="hero-outro-img"><img src="…" alt="" /></div>
    <div class="hero-outro-header">
      <h3>You become the shape that the light finally learns to find.</h3>
    </div>
  </div>
</section>

<section class="about">
  <h3>A studio built for image, motion, and the quiet glow that keeps burning after.</h3>
</section>

<script type="module" src="./script.js"></script>

Use the neutral demo copy above verbatim (uppercase editorial prose). "VoyeurVerite" is only the fictional demo name — it never appears in visible text. No real brands.

Styling

Font (CDN): PP Neue Montreal, imported via @import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");.

Palette (exact CSS variables — the dark overlay must match --base-300, the accent overlay must match --base-200):

:root {
  --base-100: #dcdbd5;  /* pale warm grey — page/bg-content background, header + about text */
  --base-200: #e1c81a;  /* gold/chartreuse accent — the flash overlay + bg-content h3 color */
  --base-300: #1a0401;  /* near-black oxblood — fg-content bg, dark overlay, dark text, about bg */
}

Global / reset:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • img { width:100%; height:100%; object-fit:cover; }
  • h1, h3 { text-transform:uppercase; font-family:"PP Neue Montreal", sans-serif; font-weight:500; line-height:0.8; letter-spacing:-3%; }
  • h1 { font-size: clamp(2rem, 8vw, 14rem); }
  • h3 { font-size: clamp(2rem, 5vw, 8rem); }
  • p { text-transform:uppercase; font-family:"PP Neue Montreal", sans-serif; font-size:0.85rem; font-weight:500; line-height:1.1; }
  • section { position:relative; width:100%; height:100svh; overflow:hidden; }

Stacking layers — .hero-fg-content and .hero-bg-content share position:absolute; top:0; left:0; width:100%; height:100%; transform-origin:center center;. The three hero layers stack by z-index:

  • .hero-fg-contentz-index:2 (top). background-color:var(--base-300); clip-path:polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); will-change:clip-path, transform; — starts as a full-screen rectangle (no clipping). transform-origin:center center matters for the later rotate + scale.
  • .hero-bg-contentz-index:0 (bottom). display:flex; background-color:var(--base-100);
  • .hero-outro-contentz-index:1 (middle). position:absolute; top:0; left:0; width:100%; height:100%; display:flex;

Foreground internals:

  • .hero-fg-img { position:absolute; width:100%; height:100%; } — the full-bleed hero photo.
  • .hero-fg-header { position:absolute; bottom:0; left:50%; transform:translateX(-50%); width:95%; padding:2rem; color:var(--base-100); text-align:center; } — the big h1 pinned to the bottom-center.
  • .hero-fg-overlay-dark, .hero-fg-overlay { position:absolute; top:0; left:0; width:100%; height:100%; opacity:0; will-change:opacity; }both start fully transparent. .hero-fg-overlay-dark { background-color:var(--base-300); } (dark), .hero-fg-overlay { background-color:var(--base-200); } (gold accent). Both sit inside .hero-fg-content, so they are clipped by its clip-path (they only ever show inside the slit).

Background text columns:

  • .hero-bg-content-col { flex:1; height:100%; display:flex; align-items:center; padding:2rem; }
  • .hero-bg-content-col:nth-child(2) { justify-content:flex-end; } — second column right-aligned.
  • .hero-bg-content-copy { display:flex; flex-direction:column; gap:0.5rem; width:50%; will-change:transform; }
  • .hero-bg-content-copy h3 { color:var(--base-200); } (gold), .hero-bg-content-copy p { color:var(--base-300); } (dark). Left column reads "Motion" top-left; right column reads "Silence", pushed to the right edge.

Outro images (side-by-side, each half width) — initial clip-paths are the collapsed starting states GSAP grows from:

  • .hero-outro-img { flex:1; height:100%; will-change:clip-path; }
  • .hero-outro-img:nth-child(1) { clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%); }zero-height, collapsed to the TOP edge (invisible; reveals downward).
  • .hero-outro-img:nth-child(2) { clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%); }zero-height, collapsed to the BOTTOM edge (invisible; reveals upward).
  • .hero-outro-header { position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); color:var(--base-100); text-align:center; width:60%; } — centered headline overlaid on the two images.
  • .hero-outro-header .line { position:relative; will-change:transform; } — the class SplitText assigns to each masked line.

.about:

  • background-color:var(--base-300); display:flex; justify-content:center; align-items:center;
  • .about h3 { text-align:center; color:var(--base-100); width:60%; }

Responsive @media (max-width:1000px):

  • .hero-bg-content-copy { width:100%; }
  • .hero-bg-content-col:nth-child(1) .hero-bg-content-copy { margin-top:-50svh; } (nudge first column up)
  • .hero-bg-content-col:nth-child(2) .hero-bg-content-copy { margin-top:50svh; } (nudge second column down)
  • .about h3, .hero-outro-header { width:100%; padding:2rem; }

GSAP effect (the important part — be exhaustive)

Smooth-scroll wiring (Lenis ↔ GSAP ticker)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
SplitText setup (masked lines, hidden below)
const outroHeaderSplit = SplitText.create(".hero-outro-header h3", {
  type: "lines",
  mask: "lines",         // each line wrapped in an overflow-clipping mask
  linesClass: "line",
});
gsap.set(outroHeaderSplit.lines, { y: "100%" });   // all lines start pushed fully below their mask (hidden)
Cache the animated elements
const fgContent      = document.querySelector(".hero-fg-content");
const fgOverlayDark   = document.querySelector(".hero-fg-overlay-dark");
const fgOverlayAccent = document.querySelector(".hero-fg-overlay");
const bgCopyLeft      = document.querySelectorAll(".hero-bg-content-copy")[0];
const bgCopyRight     = document.querySelectorAll(".hero-bg-content-copy")[1];
const outroImgTop     = document.querySelectorAll(".hero-outro-img")[0];   // left image
const outroImgBottom  = document.querySelectorAll(".hero-outro-img")[1];   // right image
let areOutroLinesRevealed = false;   // latch so the headline tween fires only once per crossing
The single ScrollTrigger — pinned, scrubbed, hand-driven via onUpdate
ScrollTrigger.create({
  trigger: ".hero",
  start: "top top",
  end: `+=${window.innerHeight * 5}px`,   // scroll runway = 5 viewport heights
  pin: true,
  pinSpacing: true,                        // ScrollTrigger inserts the spacer (no manual margin runway)
  scrub: 1,                                // 1s catch-up smoothing on the scrub
  onUpdate: (self) => {
    const scrollProgress = self.progress;  // 0 → 1 across the 5vh runway
    /* four phases, computed below */
  },
});

Every property below is set imperatively with gsap.set(...) inside onUpdate — there is no timeline and no per-tween ease; motion is purely the scroll-position → progress mapping, so it is linear within each phase and freezes wherever the scroll parks. Each phase carves out a sub-range of scrollProgress via clamp(0, 1, (scrollProgress - start) / span) and drives values with gsap.utils.interpolate(from, to, phaseProgress).

Phase 1 — slit clip + dark overlay fade-in. Range [0, 0.25]: phase1Progress = clamp(0, 1, scrollProgress / 0.25).

  • Clip the foreground inward to a central vertical slit:

``js const slitLeftEdge = interpolate(0, 48, phase1Progress); // 0% → 48% const slitRightEdge = interpolate(100, 52, phase1Progress); // 100% → 52% gsap.set(fgContent, { clipPath: polygon(${slitLeftEdge}% 0%, ${slitRightEdge}% 0%, ${slitRightEdge}% 100%, ${slitLeftEdge}% 100%), }); ` So the full rectangle narrows to a 4%-wide full-height slit (48%→52%) at screen center. Outside the slit, the z:0 cream .hero-bg-content` (the "Motion"/"Silence" columns) shows through.

  • Simultaneously fade the dark overlay in over the slit contents:

``js gsap.set(fgOverlayDark, { opacity: interpolate(0, 1, phase1Progress) }); // 0 → 1 `` By the end of phase 1 the slit is a solid dark bar (the hero image inside it is fully darkened).

Phase 2 — rotate the slit. Range [0.25, 0.45]: phase2Progress = clamp(0, 1, (scrollProgress - 0.25) / 0.2).

gsap.set(fgContent, { rotate: interpolate(0, 65, phase2Progress) });   // 0° → 65°

The whole (now dark, slit-clipped) foreground rotates about its center from 0° to 65°.

Phase 3 — scale the slit to zero + slide the text columns apart + accent flash. Range [0.45, 0.65]: phase3Progress = clamp(0, 1, (scrollProgress - 0.45) / 0.2).

  • Shrink the rotated slit to nothing:

``js gsap.set(fgContent, { scale: interpolate(1, 0, phase3Progress) }); // 1 → 0 ``

  • Slide the two background copy blocks apart along X (they've been visible behind the slit this whole time):

``js gsap.set(bgCopyLeft, { x: ${interpolate(0, 100, phase3Progress)}% }); // left copy → +100% gsap.set(bgCopyRight, { x: ${interpolate(0, -100, phase3Progress)}% }); // right copy → −100% ``

  • Accent flash — a faster sub-phase [0.45, 0.50]: phase3OverlayProgress = clamp(0, 1, (scrollProgress - 0.45) / 0.05):

``js gsap.set(fgOverlayAccent, { opacity: interpolate(0, 1, phase3OverlayProgress) }); // 0 → 1 over just 5% of scroll ` The gold --base-200` overlay snaps to full opacity inside the first quarter of phase 3, so the slit flashes gold right as it begins collapsing/shrinking away.

Phase 4 — outro images wipe in. Range [0.65, 0.85]: phase4Progress = clamp(0, 1, (scrollProgress - 0.65) / 0.2).

  • Left image reveals top-down (its bottom clip edge grows):

``js const topImgBottomEdge = interpolate(0, 100, phase4Progress); // 0% → 100% gsap.set(outroImgTop, { clipPath: polygon(0% 0%, 100% 0%, 100% ${topImgBottomEdge}%, 0% ${topImgBottomEdge}%), }); ``

  • Right image reveals bottom-up (its top clip edge shrinks):

``js const bottomImgTopEdge = interpolate(100, 0, phase4Progress); // 100% → 0% gsap.set(outroImgBottom, { clipPath: polygon(0% ${bottomImgTopEdge}%, 100% ${bottomImgTopEdge}%, 100% 100%, 0% 100%), }); `` The two half-width images (z:1, above the cream bg-content) fill in from opposite vertical edges toward full coverage.

Outro headline reveal — real tween, latched at 90%. This is NOT scrubbed; it is a genuine gsap.to fired once when progress crosses 0.9 in either direction:

if (scrollProgress >= 0.9 && !areOutroLinesRevealed) {
  areOutroLinesRevealed = true;
  gsap.to(outroHeaderSplit.lines, {
    y: "0%",           // rise up into view from behind the mask
    duration: 0.75,
    stagger: 0.1,      // top line first, each subsequent line +0.1s
    ease: "power3.out",
  });
} else if (scrollProgress < 0.9 && areOutroLinesRevealed) {
  areOutroLinesRevealed = false;
  gsap.to(outroHeaderSplit.lines, {
    y: "100%",         // drop back below the mask
    duration: 0.25,
    stagger: -0.05,    // reverse order, faster
    ease: "power3.out",
  });
}

So the masked headline lines slide up (staggered, power3.out) when you scroll past 90% and slide back down (quicker, reversed stagger) if you scroll back up past that line.

No CustomEase, no lerp/rAF loop, no Three.js. The entire effect is one pinned, scrubbed ScrollTrigger whose onUpdate hand-drives clip-path / opacity / rotate / scale / x with interpolate, plus the one latched SplitText line tween.

Assets / images

Three full-bleed silhouette-against-engineered-light editorial photos, object-fit: cover. Every image is a dark near-black frame dominated by warm orange-to-red light, with a single human head-and-shoulders in flat black profile silhouette — cohesive enough to read as one campaign:

  • hero (foreground) — the single hero photo that gets clipped to a slit, rotated and scaled away. Fills the whole viewport (source ~landscape/16:9; cover crops it). A head-and-shoulders profile silhouette facing left at center, backed by two bright orange-red spotlight beams crossing in a large X shape over a black stage; dominant colors are vivid red/orange against black.
  • outro image 1 (left) — revealed top-down. Fills a half-width, full-height column (renders ~portrait/2:3 under cover). A profile silhouette pushed to the right edge, facing left over a soft field of glowing out-of-focus orange/amber bokeh circles filling the left of the frame on black.
  • outro image 2 (right) — revealed bottom-up. Same half-width column (~portrait/2:3). A profile silhouette on the left edge facing right, with two orange-red light beams crossing behind it — one bright horizontal streak and one diagonal shaft — over a deep red-black background.

Warm-dark palette throughout (flat black silhouettes + orange/red glow, no other hues). No brands or logos. If you have fewer than three, repeat.

Behavior notes

  • Trigger: scroll only. The four phases are fully scrubbed (park the scroll and the sequence freezes mid-phase; reverse and it plays back). Only the outro headline is a discrete tween latched at the 90% mark.
  • Fresh load (progress 0): the foreground is a full rectangle showing the hero photo + the bottom-center h1; the dark and accent overlays are transparent; both outro images are clipped to zero; all headline lines sit hidden below their masks.
  • pinSpacing: true means ScrollTrigger creates the scroll spacer itself — do NOT add a manual margin-top runway on .about. The runway length is window.innerHeight * 5.
  • Heights use svh so mobile browser chrome doesn't break the full-screen layout.
  • Keep every will-change hint (clip-path, transform on fg-content; opacity on both overlays; clip-path on outro images; transform on bg copies and .line) — they matter for smooth clip-path/opacity animation.
  • No reduced-motion guard in the original. Effect runs at all viewport sizes; the max-width:1000px rules only re-flow the background copy columns.