All components

Halide — Masked Portrait Reveal Scroll

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

Open live demo ↗ Raw prompt (.md)

What it does

A pinned spotlight section where a tall grid of desaturated images scrolls vertically while an SVG-masked banner image is revealed and scaled up through a growing CSS mask-size, finishing with a word-by-word headline fade-in. A single ScrollTrigger (pin + scrub over 7x viewport height, Lenis-smoothed) drives everything from its onUpdate, mapping scroll progress to image translateY, mask-size, image scale and SplitText word opacity across staged progress ranges.

How it's built

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

scroll pin scrub css-mask image-reveal splittext lenis editorial 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

Masked Image Reveal Scroll Animation (Pinned Spotlight + Growing SVG Mask)

Goal

Build a cinematic scroll-story section: after an intro screen, a spotlight section pins for 7 viewport-heights. During the first half of the pin, a 300svh-tall grid of desaturated portrait photos scrolls vertically upward past a fixed centered headline. Overlapping it (progress 0.25 → 0.75), a full-viewport banner image is revealed through a CSS mask shaped like a bold inverted-Y emblem whose mask-size grows from 0% to 450% while the image inside scales down 1.5 → 1. In the final stretch (0.75 → 0.95) a second headline appears word by word (hard opacity toggles via SplitText). One ScrollTrigger with onUpdate drives everything; scroll is smoothed with Lenis.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger and SplitText plugins, plus lenis:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";

Everything runs inside a DOMContentLoaded listener. Register both plugins, then wire Lenis the standard way:

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

No timelines, no tweens — the entire effect is one ScrollTrigger.create() whose onUpdate writes absolute states with gsap.set and direct style.setProperty calls.

Layout / HTML

<body>
  <nav>
    <img src="(logo)" alt="" />
  </nav>

  <section class="intro">
    <div class="header"><h1>Awaken the Scroll</h1></div>
  </section>

  <section class="spotlight">
    <div class="header"><h1>Where Frames Fade Into Fate</h1></div>

    <div class="spotlight-images">
      <div class="row">
        <div class="img"></div>
        <div class="img"><img src="(photo-1)" /></div>
        <div class="img"></div>
        <div class="img"><img src="(photo-2)" /></div>
      </div>
      <div class="row">
        <div class="img"><img src="(photo-3)" /></div>
        <div class="img"></div>
        <div class="img"></div>
        <div class="img"></div>
      </div>
      <div class="row">
        <div class="img"></div>
        <div class="img"><img src="(photo-4)" /></div>
        <div class="img"><img src="(photo-5)" /></div>
        <div class="img"></div>
      </div>
      <div class="row">
        <div class="img"></div>
        <div class="img"><img src="(photo-6)" /></div>
        <div class="img"></div>
        <div class="img"><img src="(photo-7)" /></div>
      </div>
      <div class="row">
        <div class="img"><img src="(photo-8)" /></div>
        <div class="img"></div>
        <div class="img"><img src="(photo-9)" /></div>
        <div class="img"></div>
      </div>
    </div>

    <div class="mask-container">
      <div class="mask-img"><img src="(banner)" alt="" /></div>
      <div class="header"><h1>The Last Frame Hits Hard</h1></div>
    </div>
  </section>

  <section class="outro">
    <h1>End of Act One</h1>
  </section>
  <script type="module" src="./script.js"></script>
</body>

5 rows × 4 tiles = 20 .img slots, but only 9 contain an <img> in this exact scatter pattern (row 1: slots 2 & 4 · row 2: slot 1 · row 3: slots 2 & 3 · row 4: slots 2 & 4 · row 5: slots 1 & 3). The empty .img divs stay in the flow to preserve the sparse checkerboard rhythm. The JS queries .spotlight-images, .mask-container, .mask-img, and .mask-container .header h1 — keep those exact.

Styling

Google Font: "Barlow Condensed" (load all weights; 900 is the one used).

  • Global reset * { margin:0; padding:0; box-sizing:border-box; }; img { width:100%; height:100%; object-fit:cover; }.
  • h1 { text-transform:uppercase; font-family:"Barlow Condensed"; font-size:6rem; font-weight:900; line-height:0.85; letter-spacing:-0.02rem; } — huge condensed poster type.
  • nav: glassmorphism pill — position:fixed; top:2rem; left:50%; transform:translateX(-50%); width:35%; padding:1rem 0; flex-centered, background:rgba(255,255,255,0.2); backdrop-filter:blur(10px); border:1px solid rgba(255,255,255,0.1); border-radius:0.5rem; z-index:2;. Its logo img: width:1.5rem; object-fit:contain;.
  • Every section: position:relative; width:100vw; height:100svh; background:#161616; color:#fff; overflow:hidden;. .spotlight overrides background to #101010. .intro/.outro are flex-centered.
  • .header (used in intro, spotlight, and inside the mask): position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; width:50%; z-index:1;.
  • .spotlight-images: position:absolute; top:0; left:0; width:100vw; height:300svh; flex column with justify-content:space-between; and initial CSS state transform:translateY(5%) (must match the JS start value so there's no jump); will-change:transform;.
  • .row { width:100%; padding:2rem; display:flex; gap:2rem; }; .img { flex:1; aspect-ratio:5/7; overflow:hidden; }; grid photos are muted: .img img { opacity:0.5; filter:saturate(0); }.
  • .mask-container: position:absolute; top:0; left:0; width:100vw; height:100svh; overflow:hidden; z-index:10; with the CSS mask:

``css -webkit-mask: url(./spotlight-mask.svg) center / contain no-repeat; mask: url(./spotlight-mask.svg) center / contain no-repeat; -webkit-mask-size: 0%; mask-size: 0%; ` (center position is what makes the reveal bloom from the middle as the size grows.) .mask-img { width:100%; height:100%; }`.

  • @media (max-width:1000px): h1 { font-size:4rem; }; nav and .header become width:calc(100% - 4rem);; .spotlight-images { width:200vw; left:-25vw; } so the tiles keep a chunky portrait size on narrow screens.
The mask SVG

Create spotlight-mask.svg in the project: a single solid-black filled path on a transparent background — a bold inverted-Y emblem: a thick vertical stem dropping from the top edge to about half height, then splitting into two thick diagonal legs that spread down to the bottom-left and bottom-right corners (like an upward arrow / bird-foot glyph). Exact geometry:

<svg width="800" height="693" viewBox="0 0 800 693" fill="none" xmlns="http://www.w3.org/2000/svg">
  <path d="M800 515.749L501.926 343.832V0H297.482V343.832L0 515.749L101.926 693L399.408 521.084L697.482 693L800 515.749Z" fill="black"/>
</svg>

GSAP effect (the important part — be exhaustive)

Setup measurements (taken once on load)
const spotlightContainerHeight = spotlightImages.offsetHeight; // 300svh in px
const viewportHeight = window.innerHeight;
const initialOffset = spotlightContainerHeight * 0.05;         // the CSS translateY(5%)
const totalMovement = spotlightContainerHeight + initialOffset + viewportHeight;
SplitText on the mask headline

Split .mask-container .header h1 by words:

headerSplit = SplitText.create(maskHeader, { type: "words", wordsClass: "spotlight-word" });
gsap.set(headerSplit.words, { opacity: 0 });
The single ScrollTrigger
ScrollTrigger.create({
  trigger: ".spotlight",
  start: "top top",
  end: `+=${window.innerHeight * 7}px`, // 7 viewport-heights of pinned scroll
  pin: true,
  pinSpacing: true,
  scrub: 1,                             // 1s catch-up smoothing
  onUpdate: (self) => { /* three phases below */ },
});

Everything reads progress = self.progress and writes absolute values — no ease, no duration, no delay, no stagger anywhere; all ramps are linear functions of progress and the floaty feel comes entirely from scrub: 1 + Lenis inertia. Being scrub-driven, every phase is fully reversible.

Phase A — image grid scroll-through (progress 0 → 0.5): only while progress <= 0.5:

const imagesMoveProgress = progress / 0.5;
const startY = 5;                                                    // %
const endY = -(totalMovement / spotlightContainerHeight) * 100;      // ≈ −138%
const currentY = startY + (endY - startY) * imagesMoveProgress;
gsap.set(spotlightImages, { y: `${currentY}%` });

The 300svh grid starts nudged 5% down and travels linearly up until it has fully exited above the viewport (its own height + the initial offset + one viewport). Past 0.5 nothing is written, so it just stays parked off-screen.

Phase B — mask reveal (progress 0.25 → 0.75, overlapping phase A): three branches:

  • 0.25 ≤ progress ≤ 0.75:

``js const maskProgress = (progress - 0.25) / 0.5; const maskSize = ${maskProgress * 450}%; // 0% → 450% const imageScale = 1.5 - maskProgress * 0.5; // 1.5 → 1 maskContainer.style.setProperty("-webkit-mask-size", maskSize); maskContainer.style.setProperty("mask-size", maskSize); gsap.set(maskImage, { scale: imageScale }); ` The inverted-Y cutout blooms from a point at the center; once mask-size` passes ~100% the shape outgrows the viewport and the banner becomes effectively fullscreen. Meanwhile the banner image inside settles from an oversized 1.5 to its natural size — a slow push-back that sells the reveal.

  • progress < 0.25: hard-hold mask-size: 0% (both properties) and scale: 1.5.
  • progress > 0.75: hard-hold mask-size: 450% and scale: 1.

Phase C — word-by-word headline (progress 0.75 → 0.95): three branches on the SplitText words:

  • 0.75 ≤ progress ≤ 0.95:

``js const textProgress = (progress - 0.75) / 0.2; headerSplit.words.forEach((word, index) => { const wordRevealProgress = index / totalWords; gsap.set(word, { opacity: textProgress >= wordRevealProgress ? 1 : 0 }); }); `` Words pop in sequentially left-to-right as binary opacity switches (0 or 1) — a typewriter-like reveal with no fades, no stagger tweens.

  • progress < 0.75: all words opacity: 0.
  • progress > 0.95: all words opacity: 1. The last 5% of the pin holds the finished frame.

Assets / images

  • 9 portrait editorial photographs, 5:7 aspect — moody, cinematic people/scene shots for the grid tiles. CSS renders them desaturated (saturate(0)) at 50% opacity, so tonal contrast matters more than color.
  • 1 wide cinematic banner photograph, landscape (≥16:9, full-viewport cover) — the hero image revealed through the mask; it must hold up fullscreen.
  • 1 small monochrome logo mark, square-ish, transparent background (~1.5rem display size) for the glass nav pill.
  • 1 SVG mask file (spotlight-mask.svg) — created inline per the Styling section, not a photo.

Keep all copy neutral (the demo headlines above) — no real brand names.

Behavior notes

  • Total experience: intro (1 viewport) → spotlight pinned for 7 × innerHeight of scroll → outro (1 viewport). pinSpacing: true provides the scroll runway.
  • Choreography overlap is the signature: the grid is still rushing upward (phase A ends at 0.5) while the mask is already blooming (phase B starts at 0.25) — the reveal punches through the moving grid. .mask-container sits at z-index:10, so as the mask grows it covers both the grid and the "Where Frames Fade Into Fate" headline behind it.
  • The centered spotlight headline never animates — it's a fixed backdrop the grid scrolls past.
  • Measurements are captured once on DOMContentLoaded; no resize handler in the original.
  • Sections use 100svh so mobile browser chrome doesn't clip; the effect runs unchanged on mobile (≤1000px only re-sizes type, widens nav/headers, and lets the grid bleed to 200vw).
  • No CustomEase, no Three.js, no lerp/rAF loop beyond the standard Lenis ticker, no reduced-motion branch in the original.

Images

This component ships with 12 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/karim-saab-scroll-animation/img1.jpg
https://motionprompts.dev/c/karim-saab-scroll-animation/img2.jpg
https://motionprompts.dev/c/karim-saab-scroll-animation/img3.jpg
https://motionprompts.dev/c/karim-saab-scroll-animation/img4.jpg
https://motionprompts.dev/c/karim-saab-scroll-animation/img5.jpg
https://motionprompts.dev/c/karim-saab-scroll-animation/img6.jpg
… 6 more under https://motionprompts.dev/c/karim-saab-scroll-animation/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--ink, --panel, --paper, --silver, --safelight. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.

Adapting this to React

Everything above describes a standalone document: one script that waits for DOMContentLoaded, wires a single Lenis instance into the gsap.ticker, and drives all three phases of the effect — the grid scroll-through, the mask bloom, the word-by-word headline — off one ScrollTrigger's onUpdate, writing every frame's state with direct gsap.set and style.setProperty calls and never undoing any of it, because the page it lives on never unmounts. React withdraws that guarantee, and it does it quietly: the pin and the reveal look right on first load, and the damage only shows up on a second mount.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything here: two pinned ScrollTriggers stacked on the same .spotlight section, each with its own spacer, both writing conflicting y to .spotlight-images and conflicting mask-size to .mask-container on the same scrub tick; two Lenis instances both calling raf off the same gsap.ticker; and a second SplitText.create run against a headline whose words are already split, nesting spans inside spans so the .spotlight-word targets the newer onUpdate closure reads no longer line up with the ones the older closure is still writing to. None of this reproduces in a production build, because React only double-invokes in development.

*(1) The entry point* — the whole effect is the body of document.addEventListener("DOMContentLoaded", () => { ... }). By the time a React component mounts, that event has already fired, so the listener would simply never run — no pin, no mask, no split, nothing to debug. Delete the listener and move its contents into a useEffect with an empty dependency array. gsap.registerPlugin(ScrollTrigger, SplitText) is one-time, page-wide configuration; move it to module scope next to the imports instead of re-running it on every mount.

*(2) Element lookups* — .spotlight-images, .mask-container, .mask-img, and .mask-container .header h1 are each resolved with document.querySelector, and the trigger itself is created against the string ".spotlight". Give the component a root ref on the <section className="spotlight"> element — that's what the pin and every measurement are relative to — and resolve all four lookups from it instead of document. Pass the ref itself as trigger in ScrollTrigger.create rather than the selector string. During the StrictMode remount two .spotlight subtrees exist for an instant, and an unscoped lookup can bind to the copy on its way out; worse, spotlightContainerHeight = spotlightImages.offsetHeight, measured once against a detached node, comes back stale or zero and wrecks the endY math the whole grid phase depends on.

*(3) Cleanup* — wrap the SplitText.create call and the single ScrollTrigger.create in one gsap.context scoped to the root ref:

useEffect(() => {
  const lenis = new Lenis();
  const onTick = (time) => lenis.raf(time * 1000);

  const ctx = gsap.context(() => {
    lenis.on("scroll", ScrollTrigger.update);
    gsap.ticker.add(onTick);
    gsap.ticker.lagSmoothing(0);

    // measurements, SplitText.create(maskHeader, ...), and the ScrollTrigger.create
    // with its onUpdate exactly as above, trigger pointed at the root ref
  }, rootRef);

  return () => {
    gsap.ticker.remove(onTick);
    lenis.destroy();
    ctx.revert();
  };
}, []);

Nothing here needs ctx.add/self: the onUpdate callback only ever calls gsap.set and style.setProperty synchronously, on every scrub frame, and no click handler or code outside the factory ever has to call back into something registered under a name. ctx.revert() undoes the pinned trigger — which also removes its spacer and un-pins .spotlight — the SplitText created inside the same factory, and every value the three onUpdate phases wrote through gsap.set (.spotlight-images's y, .mask-img's scale, each .spotlight-word's opacity). It does not reach the two plain writes the mask phase makes directly on maskContainer.stylestyle.setProperty("mask-size", ...) and its -webkit- twin bypass GSAP entirely, so they aren't GSAP state and the context never sees them. Left alone, whichever mask-size the pin last wrote stays sitting on the element after ctx.revert(), on the same DOM node StrictMode's fake unmount reuses for the fake remount — so a fresh ScrollTrigger, created at progress zero, can start life with the mask already blooming from the previous run. Clear both properties explicitly in the same cleanup (maskContainer.style.removeProperty("mask-size") and "-webkit-mask-size") so a remount always starts from the same unrevealed state the CSS default gives it.

gsap.ticker.add(onTick) is what drives Lenis here — this component has no requestAnimationFrame loop of its own — and a ticker subscription is neither a tween nor a trigger, so the context does not track it either. Remove it explicitly, and do it *before* lenis.destroy(): a tick that lands between the two calls invokes raf on an instance that no longer exists. lenis.on("scroll", ScrollTrigger.update) needs no separate teardown — it lives on the Lenis instance's own emitter and goes with it. This component is written as a full page and is the sole owner of the smooth scroll, so creating and destroying Lenis here is correct as long as that stays true; folded into an app that already runs Lenis elsewhere, this effect should subscribe to the shared instance instead of opening a second one that fights the first over the same wheel event.

One thing SplitText needs here that the other two phases don't: create it inside the same gsap.context factory, not before or outside it, so ctx.revert() reverts the split in the same step as the trigger that writes to its words. Revert it separately, or in the wrong order relative to the trigger's teardown, and a second mount ends up calling SplitText.create on a headline that is still split from the first. The split here is by words, not lines, which matters for one thing this component can otherwise skip: a font that hasn't finished loading ("Barlow Condensed" at the weight the headline uses) doesn't corrupt the reveal the way it would for a line split. wordRevealProgress = index / totalWords keys off DOM order, not which line a word wraps to, so a fallback-face reflow doesn't change which word onUpdate unlocks at a given progress — it only changes where that word sits on screen for the instant before the real font swaps in.