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 pluginsScrollTriggerandSplitText.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:trueso 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..1range → a fullscreen quad.material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, transparent: true, uniforms: {...} }).resize():renderer.setSize(hero.offsetWidth, hero.offsetHeight)andrenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)). Call once and onwindowresize.
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
h2sits in the bottom 125svh so it lands on the revealed cream.pointer-events:noneon 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.