All components

IronHill Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

Scroll-scrubbed WebGL dissolve over a tall 175svh hero: a Three.js fullscreen quad with an fbm-noise shader sweeps a solid cream fill up over the hero image with an organic ragged edge, its uProgress driven by Lenis scroll position. As scrolling continues, GSAP SplitText splits the hero paragraph into words and a ScrollTrigger fades them in one by one over the freshly revealed background.

How it's built

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

scroll webgl three-js shader dissolve noise reveal lenis split-text 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

Morphogenesis — Scroll-Scrubbed WebGL Dissolve Hero

Goal

Build a very tall hero (175svh) where scrolling makes a solid cream fill sweep upward over a full-bleed portrait, dissolving the image away behind an organic, ragged, fbm-noise edge. The fill is a Three.js fullscreen quad running a custom GLSL shader whose uProgress uniform is driven by Lenis scroll position — so it is fully scroll-scrubbed, not time-based. Once the cream has covered the lower part of the hero, a long paragraph sitting over it fades in one word at a time, each word's opacity tied directly to a ScrollTrigger's progress. Below, a dark closing section. The star effect is the noisy WebGL dissolve reveal.

Tech

Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:

  • three — WebGL scene/renderer/shader material.
  • gsap (3.x) plus the plugins ScrollTrigger and SplitText.
  • lenis — smooth scroll; it also owns the scroll value that drives the shader.
import { vertexShader, fragmentShader } from "./shaders.js";
import * as THREE from "three";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger, SplitText);

Keep the two GLSL shader strings in a separate shaders.js module that exports vertexShader and fragmentShader.

Lenis ↔ GSAP wiring
const lenis = new Lenis();
function raf(time) {
  lenis.raf(time);
  ScrollTrigger.update();
  requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
lenis.on("scroll", ScrollTrigger.update);

Layout / HTML

Two sections. The hero stacks four absolutely-positioned layers (image, header text, WebGL canvas, content paragraph); the WebGL canvas sits above the image and header in paint order so the dissolve covers them.

<section class="hero">
  <div class="hero-img">
    <img src="/path/to/portrait.jpg" alt="" />
  </div>

  <div class="hero-header">
    <h1>Morphogenesis</h1>
    <p>Solid form gives way to liquid movement.</p>
  </div>

  <canvas class="hero-canvas"></canvas>

  <div class="hero-content">
    <h2>
      An underlying field of motion pushes and pulls the image across its
      surface, redistributing pixels in a way that feels organic and
      constantly in flux.
    </h2>
  </div>
</section>

<section class="about">
  <p>
    This animation is driven by a real-time WebGL displacement process where
    interaction introduces force into the surface, causing form to bend,
    stretch, and reorganize dynamically. Rather than relying on fixed
    keyframes, the visual state evolves continuously, allowing motion to feel
    organic, responsive, and materially present as the page progresses.
  </p>
</section>

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

Load-bearing selectors used by JS: .hero, .hero-canvas, .hero-content h2. Keep the demo copy as-is (neutral, no brands): title "Morphogenesis", subtitle "Solid form gives way to liquid movement."

Styling

Reset: * { margin:0; padding:0; box-sizing:border-box; }. img { width:100%; height:100%; object-fit:cover; }.

Fonts (Google Fonts): Instrument Serif (headings) and Instrument Sans (body).

  • h1, h2: font-family:"Instrument Serif", text-transform:uppercase, font-weight:500, line-height:0.9.
  • h1: font-size: clamp(4rem, 7.5vw, 10rem);
  • h2: font-size: clamp(2.5rem, 4.5vw, 5rem);
  • p: font-family:"Instrument Sans", font-size:1.125rem, font-weight:400.

Color tokens (exact hex — the dissolve fill color must match --base-100):

--base-100: #ebf5df;  /* pale cream-green — the dissolve fill + about text */
--base-200: #84fe1d;  /* electric lime — hero heading + subtitle color */
--base-300: #0f0f0f;  /* near-black — hero-content h2 text + about background */

Layout / positioning (all the pieces that make the layering work):

  • .hero: position:relative; width:100%; height:175svh; color:var(--base-200); overflow:hidden;
  • .hero-img: position:absolute; width:100%; height:100%; — the portrait fills the entire 175svh hero.
  • .hero-header: position:absolute; width:100%; height:100svh; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:0.5rem; text-align:center; — title + subtitle centered in the first viewport. .hero-header p { width:75%; }.
  • .hero-canvas: position:absolute; bottom:0; width:100%; height:100%; pointer-events:none; — transparent WebGL layer over image + header.
  • .hero-content: position:absolute; bottom:0; width:100%; height:125svh; display:flex; justify-content:center; align-items:center; text-align:center; — pinned to the bottom 125svh of the hero. .hero-content h2 { width:75%; color:var(--base-300); } (dark text that lands on the revealed cream fill).
  • .about: position:relative; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; background-color:var(--base-300); color:var(--base-100); with .about p { width:40%; text-align:center; }.
  • @media (max-width:1000px): .hero-content h2, .about p { width: calc(100% - 4rem); }.

The effect (be exhaustive — WebGL dissolve + word-by-word fade)

There are three coupled systems: (A) the Three.js fullscreen-quad setup, (B) the fbm-dissolve fragment shader whose uProgress is the whole show, and (C) the Lenis-scroll → progress mapping. A separate ScrollTrigger drives (D) the SplitText word fade.

CONFIG (exact values)
const CONFIG = {
  color: "#ebf5df", // dissolve fill color (matches --base-100)
  spread: 0.5,      // how far noise perturbs the dissolve edge
  speed: 2,         // scroll→progress multiplier (dissolve completes in first half of scroll)
};
(A) Three.js scene — a single clip-space quad
  • scene = new THREE.Scene().
  • camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) — maps directly to clip space.
  • renderer = new THREE.WebGLRenderer({ canvas: document.querySelector(".hero-canvas"), alpha: true, antialias: false })alpha:true so transparent shader pixels reveal the portrait beneath.
  • geometry = new THREE.PlaneGeometry(2, 2) — a 2×2 plane that exactly fills the ortho camera's -1..1 range → a fullscreen quad.
  • material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, transparent: true, uniforms: {...} }).
  • resize(): renderer.setSize(hero.offsetWidth, hero.offsetHeight) and renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)). Call once and on window resize.

Uniforms:

uniforms: {
  uProgress:   { value: 0 },
  uResolution: { value: new THREE.Vector2(hero.offsetWidth, hero.offsetHeight) },
  uColor:      { value: new THREE.Vector3(r, g, b) }, // CONFIG.color parsed to 0..1 rgb
  uSpread:     { value: CONFIG.spread }, // 0.5
}

Parse CONFIG.color hex → normalized rgb (each channel /255) for uColor. On window resize also update uResolution to the new hero.offsetWidth/offsetHeight.

Render loop (runs continuously):

let scrollProgress = 0;
function animate() {
  material.uniforms.uProgress.value = scrollProgress;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}
animate();
(B) Fragment shader — fbm-noise dissolve (reproduce exactly)

Vertex shader just passes vUv = uv and gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0).

Fragment shader — the ragged upward-sweeping dissolve. The dissolve edge is a horizontal line at uv.y = uProgress*1.2, perturbed by value-noise fbm so it reads as an organic torn edge; below the edge alpha=1 (cream fill), above alpha=0 (transparent → portrait shows). A ~1px smoothstep keeps the edge crisp.

uniform float uProgress;
uniform vec2  uResolution;
uniform vec3  uColor;
uniform float uSpread;
varying vec2  vUv;

float Hash(vec2 p) {
  vec3 p2 = vec3(p.xy, 1.0);
  return fract(sin(dot(p2, vec3(37.1, 61.7, 12.4))) * 3758.5453123);
}

float noise(in vec2 p) {          // smoothed value noise
  vec2 i = floor(p);
  vec2 f = fract(p);
  f *= f * (3.0 - 2.0 * f);       // smoothstep interpolation
  return mix(
    mix(Hash(i + vec2(0.0, 0.0)), Hash(i + vec2(1.0, 0.0)), f.x),
    mix(Hash(i + vec2(0.0, 1.0)), Hash(i + vec2(1.0, 1.0)), f.x),
    f.y
  );
}

float fbm(vec2 p) {               // 3 octaves, 0.5 / 0.25 / 0.125
  float v = 0.0;
  v += noise(p * 1.0) * 0.5;
  v += noise(p * 2.0) * 0.25;
  v += noise(p * 4.0) * 0.125;
  return v;
}

void main() {
  vec2 uv = vUv;
  float aspect = uResolution.x / uResolution.y;
  vec2 centeredUv = (uv - 0.5) * vec2(aspect, 1.0); // aspect-correct so noise cells stay square

  float dissolveEdge = uv.y - uProgress * 1.2;      // edge rises as progress grows
  float noiseValue   = fbm(centeredUv * 15.0);      // noise frequency ×15 → fine ragged detail
  float d            = dissolveEdge + noiseValue * uSpread;

  float pixelSize = 1.0 / uResolution.y;
  float alpha = 1.0 - smoothstep(-pixelSize, pixelSize, d); // d<0 → filled, d>0 → transparent

  gl_FragColor = vec4(uColor, alpha);
}

Feel: at uProgress=0 the fill sits just below the bottom edge; as progress climbs, the cream sweeps up the full 175svh with a jagged, noisy, ever-shifting frontier, blotting out the portrait.

(C) Scroll → progress mapping (Lenis, not ScrollTrigger)

scrollProgress is computed on every Lenis scroll event, clamped to 1.1 and multiplied by CONFIG.speed, so the dissolve reaches full coverage within roughly the first half of the hero's scrollable range:

lenis.on("scroll", ({ scroll }) => {
  const heroHeight   = hero.offsetHeight;
  const windowHeight = window.innerHeight;
  const maxScroll    = heroHeight - windowHeight;
  scrollProgress = Math.min((scroll / maxScroll) * CONFIG.speed, 1.1);
});
(D) SplitText word-by-word fade (ScrollTrigger, scrubbed manually)

The hero-content paragraph is split into words; each word's opacity is a direct function of a ScrollTrigger's progress, so words illuminate sequentially left-to-right as that section scrolls through.

const heroH2 = document.querySelector(".hero-content h2");
const split  = new SplitText(heroH2, { type: "words" });
const words  = split.words;

gsap.set(words, { opacity: 0 });

ScrollTrigger.create({
  trigger: ".hero-content",
  start: "top 25%",
  end: "bottom 100%",
  onUpdate: (self) => {
    const progress = self.progress;
    const total = words.length;
    words.forEach((word, index) => {
      const wordProgress     = index / total;
      const nextWordProgress = (index + 1) / total;
      let opacity = 0;
      if (progress >= nextWordProgress) {
        opacity = 1;
      } else if (progress >= wordProgress) {
        opacity = (progress - wordProgress) / (nextWordProgress - wordProgress);
      }
      gsap.to(word, { opacity, duration: 0.1, overwrite: true });
    });
  },
});

Each word owns an equal 1/total slice of the trigger's 0→1 progress: fully lit once progress passes its slice, linearly fading in while inside its slice, hidden before it. The tiny duration:0.1 + overwrite:true keeps it responsive to scrub while smoothing per-frame jumps. No scrub property is set — this ScrollTrigger reveals purely via onUpdate.

Assets / images

  • 1 full-bleed portrait, object-fit: cover, filling a very tall (175svh) hero — so the source (a roughly 3:4 portrait) is cropped to a narrow vertical slice. The real asset is a warm, golden-hour editorial photo: a young man with curly brown hair in a coral/salmon tank top, leaning against a sage-green stucco wall on the left; behind him a sunlit Mediterranean street recedes with potted greenery (a terracotta planter, small trees) and pale cream-and-white buildings under a soft blue sky. Dominant colors are muted sage green, warm coral, terracotta and sun-washed cream. The soft, warm light and greenery read well because the pale cream dissolve progressively erases the image from the bottom up. Any high-quality editorial portrait with a warm, softly-lit subject and airy background works; no brands or logos.

Behavior notes

  • Trigger: scroll only. The dissolve is fully scrubbed by Lenis scroll position (no timeline, no autoplay); parking the scroll freezes the dissolve mid-sweep. The word fade is likewise scrubbed via ScrollTrigger onUpdate.
  • Fresh load (scroll=0): portrait fully visible with the lime title + subtitle centered; cream fill just off the bottom; the hero-content paragraph is invisible (all words opacity:0).
  • Layering: the transparent WebGL canvas paints over the image and header; the dark h2 sits in the bottom 125svh so it lands on the revealed cream. pointer-events:none on the canvas keeps it non-interactive.
  • Pixel ratio capped at 2 for perf; renderer resizes with the hero. antialias:false (the smoothstep edge handles anti-aliasing).
  • No reduced-motion guard in the original.