All components

KVS Studio Scroll-Powered Image Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

Pinned full-viewport stack of five images scrubbed over four viewport heights: each image's clip-path wipes upward to reveal the next while an orange ASCII-character dissolve band (a 16px grid of random glyphs with hash-based scatter and density falloff) dithers the wipe edge, all synced to 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 ascii dissolve dithering lenis

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

Studio Scroll-Powered Image Reveal — clip-path wipe with ASCII dissolve band

Goal

Build a pinned, full-viewport stack of five images that is scrubbed through by scrolling. As you scroll, the top image's clip-path wipes upward (its top edge slides down) to uncover the next image below it, one image at a time — four transitions total. The star effect: riding exactly on the moving wipe edge is a band of orange ASCII glyphs (a fixed 16px grid of random characters) that dithers/dissolves the boundary — a dense scatter of flickering monospace characters that fades out above and below the edge with a probabilistic density falloff, so the hard clip line reads as a granular, disintegrating transition. Everything is driven by a single pinned ScrollTrigger with scrub, synced to Lenis smooth scrolling.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scroll. Register the plugin with gsap.registerPlugin(ScrollTrigger). There is no SplitText / CustomEase / Three.js here — the dissolve is a hand-built DOM grid of <div> cells whose visibility is toggled every frame from the ScrollTrigger onUpdate callback. No GSAP tweens/timelines are used at all; all motion comes from scrub progress driving direct style writes.

Lenis wiring (exact):

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

Layout / HTML

Three stacked full-viewport <section>s in document order:

  1. <section class="intro"> — a single centered uppercase mono <p> (copy: Scroll down to decode the craft).
  2. <section class="spotlight"> — the pinned stage. Contains five <div class="spotlight-img">, each wrapping one <img>, followed by one empty <div class="dissolve-grid"> (the ASCII overlay, populated at runtime).
  3. <section class="outro"> — a single centered uppercase mono <p> (copy: The rest is under NDA).
<section class="intro"><p>Scroll down to decode the craft</p></section>
<section class="spotlight">
  <div class="spotlight-img"><img src="/…/img-1.jpg" alt="" /></div>
  <div class="spotlight-img"><img src="/…/img-2.jpg" alt="" /></div>
  <div class="spotlight-img"><img src="/…/img-3.jpg" alt="" /></div>
  <div class="spotlight-img"><img src="/…/img-4.jpg" alt="" /></div>
  <div class="spotlight-img"><img src="/…/img-5.jpg" alt="" /></div>
  <div class="dissolve-grid"></div>
</section>
<section class="outro"><p>The rest is under NDA</p></section>

Styling

Fonts

Google Fonts: DM Mono (import weights 300/400/500 with italics). Used for all <p> and for every dissolve cell.

Reset & globals
  • * { margin:0; padding:0; box-sizing:border-box; }
  • img { width:100%; height:100%; object-fit:cover; }
  • p { text-transform:uppercase; font-family:"DM Mono", monospace; font-weight:500; line-height:1; } (no explicit font-size → inherits ~16px)
  • section { position:relative; width:100%; height:100svh; overflow:hidden; }
Sections
  • .intro, .outro { padding:2rem; display:flex; justify-content:center; align-items:center; background-color:#141414; color:#fff; } — near-black bookend panels with white centered text.
Stacked images (initial state matters)
  • .spotlight-img { position:absolute; top:0; left:0; width:100%; height:100%; clip-path:polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); will-change:clip-path; }
  • The initial clip-path is the full rectangle (0% top edge) → every image starts fully visible; they are stacked and only the top one shows until it gets clipped away.
  • z-index is assigned in JS: img[i].style.zIndex = totalImages - i, so image 1 sits on top (z=5) and image 5 is at the back (z=1). Image 1 is the first to wipe away; image 5 is revealed last.
Dissolve overlay
  • .dissolve-grid { position:absolute; top:0; left:0; width:100%; height:100%; z-index:100; pointer-events:none; } — sits above all images. A CSS var is set on it in JS: --dissolve-color: #ff6426.
  • .dissolve-cell { position:absolute; background:var(--dissolve-color, #ff6426); visibility:hidden; font-family:"DM Mono", monospace; font-weight:500; line-height:1; color:#000; display:flex; align-items:center; justify-content:center; overflow:hidden; } — each cell is an orange square with a black glyph, hidden by default; the JS toggles visibility per frame. Cells are absolutely positioned and sized inline by the JS.

GSAP effect (be exhaustive)

Constants (top of module — reproduce exactly)
const dissolveCellSize            = 16;    // px, square cell
const dissolveColumns             = Math.ceil(window.innerWidth  / 16);
const dissolveRows                = Math.ceil(window.innerHeight / 16);
const dissolveSpreadAbove         = 0.25;  // normalized band half-height above edge
const dissolveSpreadBelow         = 0.25;  // normalized band half-height below edge
const dissolveScatterIntensity    = 0.15;  // random per-cell offset magnitude
const dissolveSolidCoreRadius     = 0.025; // normalized radius of near-solid core
const dissolveMinScatterAtCenter  = 0.3;   // min scatter strength inside the core
const dissolveVisibilityThreshold = 0.65;  // density gate (higher = sparser)
const dissolveColor               = "#ff6426";
const dissolveCharacters =
  "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@$%&*+=?!<>{}[]";

Derived: totalImages = 5, totalTransitions = 4, dissolveFontSize = Math.round(16*0.7) = 11, totalTravelRange = 1 + dissolveSpreadAbove + dissolveSpreadBelow = 1.5.

Build the dissolve grid (once, on load)

Nested loop row in [0, dissolveRows), col in [0, dissolveColumns). For each cell create a <div class="dissolve-cell">, set inline left = col*16 + "px", top = row*16 + "px", width = height = "16px", fontSize = "11px", and textContent = a random char from dissolveCharacters (fixed for the cell's lifetime — it does not re-randomize). Append into .dissolve-grid. Keep two parallel arrays: the DOM elements, and metadata { row, col, normalizedY: (row + 0.5) / dissolveRows }.

Deterministic per-cell hashes (no runtime randomness in the band)
function hashFromPosition(row, col, seed) {
  const raw = Math.sin(row * seed + col * (seed * 2.45)) * 43758.5453;
  return raw - Math.floor(raw); // fractional part → pseudo-random in [0,1)
}
const cellVisibilityRandom = cells.map(c => hashFromPosition(c.row, c.col, 127.1));
const cellScatterOffset    = cells.map(c => (hashFromPosition(c.row, c.col, 269.3) - 0.5) * 0.15);

cellVisibilityRandom gates each cell's appearance; cellScatterOffset is a signed offset in [-0.075, 0.075] that scatters the band edge per cell.

The single ScrollTrigger (the whole animation)
ScrollTrigger.create({
  trigger: ".spotlight",
  start: "top top",
  end: `+=${totalTransitions * window.innerHeight}`, // +=4×viewport-height
  pin: true,
  pinSpacing: true,
  scrub: true,
  onUpdate: (self) => { /* see below */ },
});

So .spotlight pins at the top of the viewport and the stack is scrubbed over 4 viewport-heights of scroll (one per transition). No tweens — the onUpdate reads self.progress (0→1) and writes styles directly.

Per-frame onUpdate logic
const scrollProgress   = self.progress;                    // 0..1
const rawPosition      = scrollProgress * totalTransitions; // 0..4
const currentTransition = Math.min(Math.floor(rawPosition), totalTransitions - 1); // 0..3
const transitionProgress = gsap.utils.clamp(0, 1, rawPosition - currentTransition); // 0..1 within current transition

// band center in normalized Y, mapped through the extended travel range:
const bandCenterY = -dissolveSpreadAbove + transitionProgress * totalTravelRange; // -0.25 .. +1.25

if (transitionProgress <= 0 || transitionProgress >= 1) {
  hideAllDissolveCells();               // no band at the exact boundaries
  updateImageClipPaths(scrollProgress, totalTravelRange);
  return;
}
updateImageClipPaths(scrollProgress, totalTravelRange);
updateDissolveBand(bandCenterY);
updateImageClipPaths(scrollProgress, travelRange) — the wipe

Loop i in [0, totalTransitions) (one per stacked image that can wipe):

const segmentStart = i / totalTransitions;        // 0, .25, .5, .75
const segmentEnd   = (i + 1) / totalTransitions;  // .25, .5, .75, 1
let segmentProgress = (scrollProgress - segmentStart) / (segmentEnd - segmentStart);
segmentProgress = gsap.utils.clamp(0, 1, segmentProgress);       // 0..1 within this image's slice
const remappedPosition = -dissolveSpreadAbove + segmentProgress * travelRange; // -0.25 .. +1.25
const clipPercent = gsap.utils.clamp(0, 100, remappedPosition * 100);          // 0..100
stackedImages[i].style.clipPath =
  `polygon(0% ${clipPercent}%, 100% ${clipPercent}%, 100% 100%, 0% 100%)`;

Meaning: the polygon keeps the bottom two corners fixed and moves the top edge from clipPercent=0% (image fully shown) down to 100% (image fully clipped/gone), so each image wipes upward off the screen to expose the image beneath it. Because of the -0.25 offset and the ×1.5 travel range, the wipe stays fully open for the first ~16.7% of the slice, wipes across the middle, and is fully closed by ~83.3% of the slice — the leading/trailing buffers are exactly where the dissolve band lives.

updateDissolveBand(bandCenterY) — the ASCII dither

For each cell i:

const rawDistance = Math.abs(cell.normalizedY - bandCenterY);
// near the exact center, damp the scatter so a near-solid core survives:
const scatterStrength = gsap.utils.clamp(
  dissolveMinScatterAtCenter, 1, rawDistance / dissolveSolidCoreRadius); // 0.3..1
const scatteredDistance =
  cell.normalizedY - bandCenterY + cellScatterOffset[i] * scatterStrength;
// normalize against the appropriate half-spread (below vs above the edge):
const normalizedDistance = scatteredDistance >= 0
  ? scatteredDistance / dissolveSpreadBelow        // /0.25
  : Math.abs(scatteredDistance) / dissolveSpreadAbove; // /0.25
if (normalizedDistance >= 1) { cell.el.style.visibility = "hidden"; continue; }
const density   = (1 - normalizedDistance) * (1 - normalizedDistance); // quadratic falloff, peaks at edge
const isVisible = density > cellVisibilityRandom[i] * dissolveVisibilityThreshold;
cell.el.style.visibility = isVisible ? "visible" : "hidden";

Net behavior: a horizontal band of orange glyph-cells is densest right on the wipe edge (bandCenterY) and probabilistically thins out over ~0.25 of the viewport height above and below, gated per-cell by its fixed hash so the pattern is stable/organic rather than shimmering randomly. hideAllDissolveCells() simply sets every cell's visibility="hidden" (used at the segment boundaries).

Timeline feel

Continuous and scrub-locked: scrolling forward wipes img1→img2→img3→img4→img5 across four viewport-heights, each transition (a) sliding the current top image's clip edge down and (b) dragging the flickering orange ASCII band along that edge. Scrolling back reverses it exactly. No easing curve, no duration/delay/stagger — the only "ease" is Lenis smoothing the scroll input.

Assets / images

Five full-bleed images, stacked to fill the whole viewport (object-fit: cover, so any source aspect works; all sources are landscape, roughly 16:9). The set is a mostly light, high-key near-monochrome studio collection — pale grays, whites and brushed silver, with one warmer interior — so the orange dissolve band pops against the bright, low-saturation backgrounds. Roles, in stack order (z-index 5 → 1):

  1. Top image (first to wipe away): a smooth matte white pebble/blob resting in a soft dimple on a seamless pale-gray surface, top-down studio render. Dominant colors: white and light gray.
  2. A brushed silver wavy liquid torus ring lying flat on a flat mid-gray background, soft metallic highlights. Dominant colors: silver/gray on neutral gray.
  3. A softly lit curved plaster interior in warm cream/beige tones: a boucle lounge chair and a small potted plant against sculpted rounded walls with hidden warm lighting. The one warm, photographic frame in the set — dominant colors: cream, beige and soft gold.
  4. A black-and-white concentric spiral / ripple pattern (op-art), fine dark rings on a near-white ground radiating from center. Dominant colors: white with thin black lines.
  5. Bottom image (revealed last): a swirling white-and-gray marble surface, glossy flowing veins in charcoal across a bright white ground. Dominant colors: white and gray.

Do not use real brand imagery. Any 5 cohesive, mostly high-key light/neutral abstract or studio images (with at most one warmer accent frame) work.

Behavior notes

  • Grid is sized at load from window.innerWidth/innerHeight (columns/rows and the ScrollTrigger end are computed once). It does not rebuild on resize — a page reload re-fits it. Keep this behavior.
  • Desktop-first, pointer-scroll experience; the effect is fully scrubbed (no autoplay, no hover/click).
  • The .dissolve-grid is pointer-events:none and z-index:100 so it never blocks interaction and always overlays the images.
  • Intro/outro panels (#141414) give the pin room to enter/exit and frame the piece.

</content> </invoke>