Signed, smoothed scroll velocity drives a single scalar, and that scalar scales two sine displacements in the vertex shader. Because the velocity keeps its sign, scrolling up bends the surface the opposite way from scrolling down, which is the direction cue that makes the effect read as momentum rather than as noise. Everything else — the wave shape, the shading, the crop — is a pure function of that one number.
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Scroll Deformation WaveSigned, smoothed scroll velocity drives a single scalar, and that scalar scales two sine displacements in the vertex shader. Because the velocity keeps its sign, scrolling up bends the surface the opposite way from scrolling down, which is the direction cue that makes the effect read as momentum rather than as noise. Everything else — the wave shape, the shading, the crop — is a pure function of that one number.Build this as a React component in my project. Match my existing conventions —file layout, styling system, and naming — rather than the ones implied below.Everything after this line is specification, not code to copy verbatim.## What it doesAn image that ripples with scroll speed and bends the other way on the way back, from two sine terms.- Category: webgl- Runtime cost: webgl- Dependencies: three@^0.185.1## How it works1. The depth term is `pos.z += sin(position.y * frequency + phase) * deform * amplitude`, and the shear term is `pos.y += sin(position.x * frequency) * deform * shear`. Both are sampled from `position` — the original flat attribute — not from the running `pos`.2. That distinction is the whole technique. Sampling `position` fixes the wave to the picture, so a given pixel always sits at the same point on the sine and the image appears to flex. Sampling the already-displaced `pos` would make each term feed the next, so the wave travels through the surface and reads as a glitch.3. The phase constant breaks the symmetry. Without it, `sin(y * f)` is odd about the plane's centre line and the top and bottom halves mirror each other exactly, which looks like a crease rather than a wave.4. The slope, not the height, drives the shading: `vBend = cos(position.y * frequency + phase) * deform` is the analytic derivative of the depth term, so crests brighten and troughs darken with one cosine and no recomputed vertex normals.5. `useScrollVelocity` supplies a two-stage smoothed signal — a low-pass on the raw delta, then a damped approach — and the component damps once more on top of it with a 0.0025 smoothing factor, so the ripple eases out for a beat after the scroll stops instead of snapping flat.6. The fragment shader cover-fits the source into the quad and divides the sampled window by an overscan factor, which is what keeps the displaced vertices from sampling past the texture edge at the crest of every wave.## Constants — use these valuesThese are tuned. Each one has a reason; do not round them off or replace themwith values that "look cleaner".- `amplitude = 0.07` World units of depth at full deflection, against a plane roughly 2.5 units tall. Below ~0.02 the ripple is lost in the perspective foreshortening; above ~0.2 the crests swing far enough toward the camera to cross the near plane and punch holes in the image.- `shear = 0.04` Deliberately about half the amplitude. Its only job is to break the perfectly horizontal banding so the surface reads as cloth; raise it toward the amplitude and the two waves beat against each other into a visible moire.- `frequency = 3` Radians per world unit, so roughly one and a half crests across the visible plane. Below ~1.5 there is less than a full wave on screen and the image just tilts; above ~8 the crests fall closer together than the vertex spacing and the wave aliases into flicker.- `phase = 100` An arbitrary large offset whose only purpose is to be nowhere near a multiple of pi. At phase 0 the sine is odd about the plane's centre, the halves mirror, and the ripple looks like a fold down the middle.- `maxDeform = 1.5` Ceiling on the signed scalar, so a trackpad flick cannot drive the displacement past the point where the surface self-intersects. Everything downstream multiplies by this, so it is the one number that bounds the entire effect.- `reference = 900 px/s` Scroll speed that counts as full deflection. The hook defaults to 1500, which is tuned for whole-page parallax; a component-sized scroller rarely reaches it, so the wave would never appear.- `DEFORM_SMOOTHING = 0.0025` Fraction of the gap remaining after one second. Slower and the wave lags visibly behind the scroll; much faster and it snaps flat the instant scrolling stops, losing the ease-out that makes it feel like momentum.- `overscan = 1.12` Twelve percent crop held in reserve. At exactly 1.0 the displaced vertices sample past the edge of the texture at every crest and the clamped border pixel smears across the top and bottom of the image.- `segments = 96` The wave is evaluated per vertex, so subdivision is its resolution. Below ~32 the sine renders as a polyline and the shading term steps in visible bands; above ~160 the vertices land inside a single pixel and only cost transform time.- `shade = 0.22` How far the slope term swings brightness. At 0 the ripple is nearly invisible on a busy image; above ~0.5 the crests blow out to white and read as a lighting bug rather than as a surface.## API```tsfunction ScrollDeformWave(props: { scrollTarget?: HTMLElement | null; amplitude?: number; shear?: number; frequency?: number; phase?: number; maxDeform?: number; reference?: number; shade?: number; overscan?: number; segments?: number; paint?: (ctx: CanvasRenderingContext2D, size: { width: number; height: number }) => void; className?: string }): JSX.Element```- `scrollTarget: HTMLElement | null` — default `null` Element whose scrollTop drives the wave. null reads window.scrollY.- `amplitude: number` — default `0.07` Depth of the ripple in world units at full deflection.- `shear: number` — default `0.04` Vertical shear, which breaks up the horizontal banding.- `frequency: number` — default `3` Spatial frequency of the sine, in radians per world unit.- `phase: number` — default `100` Phase offset, so the wave is not symmetric about the plane centre.- `maxDeform: number` — default `1.5` Clamp on the signed deform scalar.- `reference: number` — default `900` Pixels per second of scroll that count as full deflection.- `shade: number` — default `0.22` How far the slope term brightens crests and darkens troughs.- `overscan: number` — default `1.12` Crop held in reserve so displaced vertices never sample past the edge.- `segments: number` — default `96` Plane subdivisions per axis. The resolution of the wave.- `paint: (ctx, size) => void` Override the image. Receives a 2D context for a 1024x1024 canvas.- `className: string` Merged onto the wrapper via `cn`.## Reference implementation of the coreThis is the part that is hard to get right. Adapt it; do not treat the rest ofthe component as fixed.```tsxexport function ScrollDeformWave({ scrollTarget = null, amplitude = 0.07, shear = 0.04, frequency = 3, phase = 100, maxDeform = 1.5, reference = 900, shade = 0.22, overscan = 1.12, segments = 96, paint, className,}: ScrollDeformWaveProps) { const host = useRef<HTMLDivElement>(null); const stage = useRef<Stage | null>(null); const { width, height } = useResizeObserver(host); const { inView } = useInView(host, { threshold: 0 }); const visible = useVisibility(); const reduced = useReducedMotion(); // Signed and already smoothed. Raw scroll deltas are spiky enough that // feeding them straight into a geometry parameter produces visible stutter. const scroll = useScrollVelocity({ reference, target: scrollTarget }); const [supported, setSupported] = useState(true); useEffect(() => setSupported(hasWebGL()), []); const deform = useRef(0); const live = useRef({ amplitude, shear, frequency, phase, maxDeform, shade, overscan }); live.current = { amplitude, shear, frequency, phase, maxDeform, shade, overscan }; const ready = width > 1 && height > 1 && supported; useEffect(() => { const el = host.current; if (!el || !ready) return; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(pixelRatio()); renderer.setSize(el.clientWidth, el.clientHeight, false); renderer.setClearColor(0x000000, 0); renderer.domElement.style.display = 'block'; renderer.domElement.style.width = '100%'; renderer.domElement.style.height = '100%'; el.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(CAMERA_FOV, 1, 0.1, 100); camera.position.z = CAMERA_Z; const canvas = document.createElement('canvas'); canvas.width = TEXTURE_WIDTH; canvas.height = TEXTURE_HEIGHT; const ctx = canvas.getContext('2d'); if (ctx) (paint ?? paintImage)(ctx, { width: TEXTURE_WIDTH, height: TEXTURE_HEIGHT }); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy()); const uniforms: WaveUniforms = { uTexture: { value: texture }, uTextureAspect: { value: TEXTURE_WIDTH / TEXTURE_HEIGHT }, uPlaneAspect: { value: 1 }, uOverscan: { value: overscan }, uShade: { value: shade }, uDeform: { value: 0 }, uFrequency: { value: frequency }, uPhase: { value: phase }, uAmplitude: { value: amplitude }, uShear: { value: shear }, }; // A unit plane, rescaled on resize rather than rebuilt. The wave is // measured in world units, so the plane keeps a fixed subdivision and the // mesh scale carries the aspect. const geometry = new THREE.PlaneGeometry(1, 1, segments, segments); const material = new THREE.ShaderMaterial({ vertexShader: scrollDeformWaveVertexShader, fragmentShader: scrollDeformWaveFragmentShader, uniforms, transparent: true, }); const mesh = new THREE.Mesh(geometry, material); // The bend happens after the bounding sphere was computed, so a plane at // the edge of the frustum blinks out mid-ripple if this stays on. mesh.frustumCulled = false; scene.add(mesh); stage.current = { renderer, scene, camera, mesh, geometry, material, texture, uniforms }; return () => { stage.current = null; scene.remove(mesh); // Lives inside `uniforms`, which `disposeObject` never walks. texture.dispose(); disposeObject(scene); geometry.dispose(); material.dispose(); disposeRenderer(renderer); renderer.domElement.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, segments, paint]); useEffect(() => { const current = stage.current; if (!current || width < 1 || height < 1) return; const aspect = width / height; current.renderer.setPixelRatio(pixelRatio()); current.renderer.setSize(width, height, false); current.camera.aspect = aspect; current.camera.updateProjectionMatrix(); // Cover the frustum exactly at z = 0, in world units derived from the // camera — not from `window.innerWidth`, which has no fixed relationship // to the visible extent at this distance. const visibleHeight = 2 * Math.tan((CAMERA_FOV * Math.PI) / 360) * CAMERA_Z; current.mesh.scale.set(visibleHeight * aspect, visibleHeight, 1); // The shader fits the texture into the QUAD, so this is the quad's aspect. current.uniforms.uPlaneAspect.value = aspect; }, [width, height]); useRaf( (dt) => { const current = stage.current; if (!current) return; const { amplitude: depth, shear: skew, frequency: freq, phase: offset, maxDeform: limit, shade: light, overscan: zoom, } = live.current; // Signed on purpose. Taking the magnitude would make scrolling up and // scrolling down produce an identical wave, and the direction cue — the // surface bending the other way — is most of the effect. const target = reduced ? 0 : clamp(scroll.current.normalized, -1, 1) * limit; deform.current = damp(deform.current, target, DEFORM_SMOOTHING, dt); current.uniforms.uDeform.value = deform.current; current.uniforms.uAmplitude.value = depth; current.uniforms.uShear.value = skew; current.uniforms.uFrequency.value = freq; current.uniforms.uPhase.value = offset; current.uniforms.uShade.value = light; current.uniforms.uOverscan.value = zoom; current.renderer.render(current.scene, current.camera); }, ready && inView && visible, );```## RequirementsThese are not optional. A version of this component that skips any of them isnot finished.**Cleanup.** Removes the mesh from the scene, disposes the `CanvasTexture` by hand because it lives inside `material.uniforms` where `disposeObject` does not look, runs `disposeObject` over the scene, disposes the geometry and the material, then `disposeRenderer` for `setAnimationLoop(null)` / `dispose()` / `forceContextLoss()`, and removes the canvas element. The stage ref is nulled first so a queued frame is a no-op. `useScrollVelocity` unsubscribes with the shared RAF loop it was registered on.**Reduced motion.** The deform scalar is forced to 0 regardless of scroll velocity, and it is still damped there, so a component that is mid-ripple when the preference changes eases flat rather than snapping. The image then stays perfectly still while scrolling — the cover fit and the resize handling continue to work, but no vertex is ever displaced and no shading swing occurs.**Frame-rate independence.** Any smoothing must be a function of delta time.`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where`smoothing` is the fraction of the gap remaining after one second.**Strict mode.** Assume effects run twice in development. Setup must beidempotent and teardown must fully undo it.## Known pitfalls — avoid these specifically- Sampling the wave from the running `pos` instead of the original `position`. Each term then feeds the next, the ripple travels through the surface as it deforms rather than staying pinned to the image, and the effect looks like a rendering fault instead of the photograph flexing.- Taking the magnitude of the velocity. `abs(velocity)` makes scrolling up and scrolling down produce an identical wave, which throws away the direction cue that carries most of the effect. The scalar stays signed all the way into the uniform.- Using a phase of 0. `sin(y * f)` is odd about the plane's centre, so the top and bottom halves mirror each other exactly and the ripple reads as a crease down the middle rather than a wave.- Feeding raw scroll deltas into the uniform. A wheel emits discrete steps and a trackpad emits bursts; either drives the geometry into visible stutter. The signal is low-passed, damped, and damped again before it reaches the shader.- Leaving `frustumCulled` on. The bounding sphere was computed for the flat plane, so when the component sits at the edge of the viewport three culls it against geometry the shader has already moved and the image blinks out mid-ripple.- Fitting the plane with `window.innerWidth`. The plane has to cover the camera's frustum at z = 0, which is `2 * tan(fov/2) * distance` tall — a pixel width has no fixed relationship to that, so the image ends up letterboxed or overscaled depending on the viewport.- Skipping the overscan. Displaced vertices sample outside [0,1] at the crest of every wave, and the texture's clamped border smears along the top and bottom edges exactly when the wave is at its most visible.- Passing a ref to `scrollTarget`. The hook takes an `HTMLElement | null`, not a ref object, so a scroller inside the component has to be captured with a callback ref into state before it can be handed over — a plain ref would still be null on the render that matters.- Calling `renderer.dispose()` alone. That frees GPU objects and leaves the context alive; a page with several WebGL components then hits Chromium's per-process cap and the oldest canvases go blank. `disposeRenderer` calls `forceContextLoss`.## Notes- `scrollTarget` defaults to `null`, which reads `window.scrollY`. Pass the scrolling element when the component lives inside its own `overflow-y-auto` container, as the demo does.- The plane is a unit quad rescaled on resize rather than rebuilt, so a container resize never reallocates a 96x96 vertex buffer.## Definition of doneScroll the container and the image ripples in horizontal bands, with the crests catching a little more light than the troughs. Scroll the other way and the bands bow the opposite direction. Stop and the surface eases flat over about half a second rather than snapping. Flick hard and the ripple deepens but never folds through itself or shows a smeared edge. The image keeps its own proportions at any container aspect, and scrolling the page while the component is off screen costs nothing because the loop is gated on visibility. With `prefers-reduced-motion: reduce` the image is completely still at every scroll speed.When you are finished, tell me which of the requirements above you implementedand where, so I can check them.
'use client';import { useEffect, useRef, useState } from 'react';import * as THREE from 'three';import { cn } from '@/lib/utils';import { useInView } from '@/hooks/use-in-view';import { useRaf } from '@/hooks/use-raf';import { useReducedMotion } from '@/hooks/use-reduced-motion';import { useResizeObserver } from '@/hooks/use-resize-observer';import { useScrollVelocity } from '@/hooks/use-scroll-velocity';import { useVisibility } from '@/hooks/use-visibility';import { clamp, damp } from '@/lib/damp';import { disposeObject, disposeRenderer, hasWebGL, pixelRatio,} from '@/lib/webgl';import { scrollDeformWaveFragmentShader, scrollDeformWaveVertexShader,} from '@/components/motion/shaders/scroll-deform-wave';/* ========================================================================== Scroll Deformation Wave -------------------------------------------------------------------------- An image that flexes with how fast you are scrolling, and bends the other way when you scroll back. Signed velocity in, two sine terms out. The signature detail is that the wave is sampled from the plane's original flat coordinates rather than from the displaced ones, which pins the ripple to the picture instead of letting it travel through the surface. ========================================================================== */export interface ScrollDeformWaveProps { /** * Element whose `scrollTop` drives the wave. `null` (default) reads * `window.scrollY`. Pass the scroller when the component lives inside its * own overflow container. */ scrollTarget?: HTMLElement | null; /** Depth of the ripple in world units at full deflection. */ amplitude?: number; /** Vertical shear amount, which breaks up the horizontal banding. */ shear?: number; /** Spatial frequency of the sine, in radians per world unit. */ frequency?: number; /** Phase offset, so the wave is not symmetric about the plane centre. */ phase?: number; /** Clamp on the signed deform scalar. */ maxDeform?: number; /** Pixels per second of scroll that count as full deflection. */ reference?: number; /** How much the slope term brightens crests and darkens troughs. */ shade?: number; /** Extra crop held in reserve so displaced vertices never sample past the edge. */ overscan?: number; /** Plane subdivisions per axis. The wave is per-vertex, so this is its resolution. */ segments?: number; /** Draw the image. Default paints a ruled hue field with a caption. */ paint?: ( ctx: CanvasRenderingContext2D, size: { width: number; height: number }, ) => void; className?: string;}const CAMERA_FOV = 45;const CAMERA_Z = 3;const TEXTURE_WIDTH = 1024;const TEXTURE_HEIGHT = 1024;const DEFORM_SMOOTHING = 0.0025;interface WaveUniforms { [name: string]: THREE.IUniform; uTexture: THREE.IUniform<THREE.Texture | null>; uTextureAspect: THREE.IUniform<number>; uPlaneAspect: THREE.IUniform<number>; uOverscan: THREE.IUniform<number>; uShade: THREE.IUniform<number>; uDeform: THREE.IUniform<number>; uFrequency: THREE.IUniform<number>; uPhase: THREE.IUniform<number>; uAmplitude: THREE.IUniform<number>; uShear: THREE.IUniform<number>;}interface Stage { renderer: THREE.WebGLRenderer; scene: THREE.Scene; camera: THREE.PerspectiveCamera; mesh: THREE.Mesh; geometry: THREE.PlaneGeometry; material: THREE.ShaderMaterial; texture: THREE.CanvasTexture; uniforms: WaveUniforms;}function paintImage( ctx: CanvasRenderingContext2D, size: { width: number; height: number },): void { const { width, height } = size; const ramp = ctx.createLinearGradient(0, height, width, 0); ramp.addColorStop(0, 'hsl(206, 52%, 14%)'); ramp.addColorStop(0.5, 'hsl(178, 40%, 38%)'); ramp.addColorStop(1, 'hsl(52, 78%, 66%)'); ctx.fillStyle = ramp; ctx.fillRect(0, 0, width, height); // Horizontal rules run across the crests, so the ripple shows up as the // lines bowing. On a smooth gradient the deformation is nearly invisible. ctx.strokeStyle = 'rgba(255, 255, 255, 0.17)'; ctx.lineWidth = Math.max(1, height / 700); for (let i = 1; i < 28; i++) { const y = (height * i) / 28; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } ctx.fillStyle = 'rgba(255, 255, 255, 0.92)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `600 ${Math.round(height * 0.11)}px ui-sans-serif, system-ui, sans-serif`; ctx.fillText('DRIFT', width / 2, height * 0.46); ctx.fillStyle = 'rgba(255, 255, 255, 0.72)'; ctx.font = `500 ${Math.round(height * 0.028)}px ui-monospace, SFMono-Regular, monospace`; ctx.fillText('VELOCITY DRIVES THE SURFACE', width / 2, height * 0.57);}// #region criticalexport function ScrollDeformWave({ scrollTarget = null, amplitude = 0.07, shear = 0.04, frequency = 3, phase = 100, maxDeform = 1.5, reference = 900, shade = 0.22, overscan = 1.12, segments = 96, paint, className,}: ScrollDeformWaveProps) { const host = useRef<HTMLDivElement>(null); const stage = useRef<Stage | null>(null); const { width, height } = useResizeObserver(host); const { inView } = useInView(host, { threshold: 0 }); const visible = useVisibility(); const reduced = useReducedMotion(); // Signed and already smoothed. Raw scroll deltas are spiky enough that // feeding them straight into a geometry parameter produces visible stutter. const scroll = useScrollVelocity({ reference, target: scrollTarget }); const [supported, setSupported] = useState(true); useEffect(() => setSupported(hasWebGL()), []); const deform = useRef(0); const live = useRef({ amplitude, shear, frequency, phase, maxDeform, shade, overscan }); live.current = { amplitude, shear, frequency, phase, maxDeform, shade, overscan }; const ready = width > 1 && height > 1 && supported; useEffect(() => { const el = host.current; if (!el || !ready) return; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(pixelRatio()); renderer.setSize(el.clientWidth, el.clientHeight, false); renderer.setClearColor(0x000000, 0); renderer.domElement.style.display = 'block'; renderer.domElement.style.width = '100%'; renderer.domElement.style.height = '100%'; el.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(CAMERA_FOV, 1, 0.1, 100); camera.position.z = CAMERA_Z; const canvas = document.createElement('canvas'); canvas.width = TEXTURE_WIDTH; canvas.height = TEXTURE_HEIGHT; const ctx = canvas.getContext('2d'); if (ctx) (paint ?? paintImage)(ctx, { width: TEXTURE_WIDTH, height: TEXTURE_HEIGHT }); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy()); const uniforms: WaveUniforms = { uTexture: { value: texture }, uTextureAspect: { value: TEXTURE_WIDTH / TEXTURE_HEIGHT }, uPlaneAspect: { value: 1 }, uOverscan: { value: overscan }, uShade: { value: shade }, uDeform: { value: 0 }, uFrequency: { value: frequency }, uPhase: { value: phase }, uAmplitude: { value: amplitude }, uShear: { value: shear }, }; // A unit plane, rescaled on resize rather than rebuilt. The wave is // measured in world units, so the plane keeps a fixed subdivision and the // mesh scale carries the aspect. const geometry = new THREE.PlaneGeometry(1, 1, segments, segments); const material = new THREE.ShaderMaterial({ vertexShader: scrollDeformWaveVertexShader, fragmentShader: scrollDeformWaveFragmentShader, uniforms, transparent: true, }); const mesh = new THREE.Mesh(geometry, material); // The bend happens after the bounding sphere was computed, so a plane at // the edge of the frustum blinks out mid-ripple if this stays on. mesh.frustumCulled = false; scene.add(mesh); stage.current = { renderer, scene, camera, mesh, geometry, material, texture, uniforms }; return () => { stage.current = null; scene.remove(mesh); // Lives inside `uniforms`, which `disposeObject` never walks. texture.dispose(); disposeObject(scene); geometry.dispose(); material.dispose(); disposeRenderer(renderer); renderer.domElement.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, segments, paint]); useEffect(() => { const current = stage.current; if (!current || width < 1 || height < 1) return; const aspect = width / height; current.renderer.setPixelRatio(pixelRatio()); current.renderer.setSize(width, height, false); current.camera.aspect = aspect; current.camera.updateProjectionMatrix(); // Cover the frustum exactly at z = 0, in world units derived from the // camera — not from `window.innerWidth`, which has no fixed relationship // to the visible extent at this distance. const visibleHeight = 2 * Math.tan((CAMERA_FOV * Math.PI) / 360) * CAMERA_Z; current.mesh.scale.set(visibleHeight * aspect, visibleHeight, 1); // The shader fits the texture into the QUAD, so this is the quad's aspect. current.uniforms.uPlaneAspect.value = aspect; }, [width, height]); useRaf( (dt) => { const current = stage.current; if (!current) return; const { amplitude: depth, shear: skew, frequency: freq, phase: offset, maxDeform: limit, shade: light, overscan: zoom, } = live.current; // Signed on purpose. Taking the magnitude would make scrolling up and // scrolling down produce an identical wave, and the direction cue — the // surface bending the other way — is most of the effect. const target = reduced ? 0 : clamp(scroll.current.normalized, -1, 1) * limit; deform.current = damp(deform.current, target, DEFORM_SMOOTHING, dt); current.uniforms.uDeform.value = deform.current; current.uniforms.uAmplitude.value = depth; current.uniforms.uShear.value = skew; current.uniforms.uFrequency.value = freq; current.uniforms.uPhase.value = offset; current.uniforms.uShade.value = light; current.uniforms.uOverscan.value = zoom; current.renderer.render(current.scene, current.camera); }, ready && inView && visible, ); // #endregion critical return ( <div className={cn('relative h-full w-full overflow-hidden', className)}> <div ref={host} className="absolute inset-0" /> {!supported ? ( <div className="absolute inset-0" style={{ background: 'linear-gradient(30deg, hsl(206 52% 14%), hsl(178 40% 38%), hsl(52 78% 66%))', }} /> ) : null} </div> );}
/* ========================================================================== Scroll Deformation Wave — GLSL as TypeScript template literals. -------------------------------------------------------------------------- Signed scroll velocity, turned into a travelling ripple across an image. The whole effect is two sine terms in the vertex shader, and the detail that makes it work is which coordinate they are sampled from. Sampling the ORIGINAL flat attribute means the ripple is fixed to the picture: the same pixel always sits on the same part of the wave, so it reads as the photograph itself flexing. Sampling the already-displaced position instead makes the wave travel through the surface, which reads as a rendering fault. ========================================================================== */export const scrollDeformWaveVertexShader = /* glsl */ `uniform float uDeform;uniform float uFrequency;uniform float uPhase;uniform float uAmplitude;uniform float uShear;varying vec2 vUv;varying float vBend;void main() { vec3 pos = position; // Depth ripple. Runs along y, so the crests are horizontal bands that sweep // the image the way scrolling sweeps the page. uPhase offsets the sine so // the wave is not symmetric about the plane's centre — without it the top // and bottom halves mirror each other and the ripple looks like a fold. pos.z += sin(position.y * uFrequency + uPhase) * uDeform * uAmplitude; // Shear. A much smaller vertical push driven by x, which breaks the // horizontal bands just enough that the surface reads as cloth rather than // as a corrugated sheet. pos.y += sin(position.x * uFrequency) * uDeform * uShear; // Local slope, for shading. The derivative of the depth term with respect to // y is cos of the same argument, so how steeply this patch tilts is one // cosine and needs no recomputed normals. vBend = cos(position.y * uFrequency + uPhase) * uDeform; vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);}`;export const scrollDeformWaveFragmentShader = /* glsl */ `uniform sampler2D uTexture;uniform float uTextureAspect;uniform float uPlaneAspect;uniform float uOverscan;uniform float uShade;varying vec2 vUv;varying float vBend;void main() { // Cover fit, so the source keeps its own proportions inside a plane whose // aspect is set by the container. Overscan matters more here than usual: // the vertex shader has already moved these vertices, and without crop in // reserve the clamped border smears along the edge at every crest. vec2 scale = vec2(1.0); if (uTextureAspect > uPlaneAspect) { scale.x = uPlaneAspect / uTextureAspect; } else { scale.y = uTextureAspect / uPlaneAspect; } scale /= max(uOverscan, 0.0001); vec2 offset = (1.0 - scale) * 0.5; vec4 texel = texture2D(uTexture, vUv * scale + offset); // Crests brighten, troughs darken, by the slope rather than the height. A // flat-lit ripple is nearly invisible on a busy image; slope shading is what // makes the wave legible without touching the geometry. float shade = 1.0 + vBend * uShade; gl_FragColor = vec4(texel.rgb * shade, texel.a); // ShaderMaterial gets no automatic output conversion — three only injects // this chunk into its own materials. Without it every colour ships raw // linear values into an sRGB framebuffer and the whole scene looks washed. #include <colorspace_fragment>}`;
/* ========================================================================== Frame-rate-independent interpolation. -------------------------------------------------------------------------- The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice as fast on a 120 Hz display as on a 60 Hz one, so the same code feels different on different hardware and visibly speeds up when the browser drops frames. `damp` fixes it by raising the retention factor to the power of elapsed time, which is the exact continuous form of the same curve. ========================================================================== *//** * Fraction of the remaining distance to close this frame. * * @param smoothing Fraction of the gap that REMAINS after one second. * Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft, * 0.5 ≈ slow drift. * @param dt Seconds elapsed since the previous frame. */export function dampFactor(smoothing: number, dt: number): number { return 1 - Math.pow(smoothing, dt);}/** Move `current` toward `target` at a rate independent of frame rate. */export function damp( current: number, target: number, smoothing: number, dt: number,): number { return current + (target - current) * dampFactor(smoothing, dt);}/** * The per-frame lerp constant an author measured at 60fps, converted into the * `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame" * be ported without re-tuning by eye: * * damp(x, t, perFrameToSmoothing(0.1), dt) */export function perFrameToSmoothing(alpha: number, baseFps = 60): number { return Math.pow(1 - alpha, baseFps);}export function lerp(a: number, b: number, t: number): number { return a + (b - a) * t;}export function clamp(v: number, min: number, max: number): number { return v < min ? min : v > max ? max : v;}export function inverseLerp(a: number, b: number, v: number): number { return a === b ? 0 : (v - a) / (b - a);}export function mapRange( v: number, inMin: number, inMax: number, outMin: number, outMax: number, doClamp = true,): number { const t = inverseLerp(inMin, inMax, v); const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t); return r;}/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */export function smoothstep(edge0: number, edge1: number, x: number): number { const t = clamp(inverseLerp(edge0, edge1, x), 0, 1); return t * t * (3 - 2 * t);}/** * Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at * distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and * no branches, which is why carousels that use it feel analogue rather than * stepped. */export function gaussian(distance: number, falloff = 1): number { const d = distance * falloff; return Math.exp(-(d * d));}
'use client';import { useEffect, useRef, useState, type RefObject } from 'react';/* ========================================================================== IntersectionObserver, sharing one observer per option set. -------------------------------------------------------------------------- A hundred cards each constructing their own observer is a hundred observers the compositor has to service. Keying a shared observer by its options collapses that to one per distinct configuration — in practice, two or three for the whole site. ========================================================================== */export interface InViewState { inView: boolean; /** 0-1. The preview slot allocator ranks candidates by this. */ ratio: number;}export interface UseInViewOptions { root?: Element | null; rootMargin?: string; /** Single number or list, same meaning as the native option. */ threshold?: number | number[]; /** Stop observing after the first time it becomes visible. */ once?: boolean;}type Entry = { observer: IntersectionObserver; handlers: Map<Element, (e: IntersectionObserverEntry) => void>;};const pools = new Map<string, Entry>();function poolFor(options: UseInViewOptions): Entry { const key = JSON.stringify({ rootMargin: options.rootMargin ?? '0px', threshold: options.threshold ?? 0, }); const existing = pools.get(key); if (existing) return existing; const handlers = new Map<Element, (e: IntersectionObserverEntry) => void>(); const observer = new IntersectionObserver( (entries) => { for (const entry of entries) handlers.get(entry.target)?.(entry); }, { root: options.root ?? null, rootMargin: options.rootMargin ?? '0px', threshold: options.threshold ?? 0, }, ); const created: Entry = { observer, handlers }; pools.set(key, created); return created;}export function useInView( ref: RefObject<Element | null>, options: UseInViewOptions = {},): InViewState { const [state, setState] = useState<InViewState>({ inView: false, ratio: 0 }); const { root = null, rootMargin = '0px', once = false } = options; const thresholdKey = JSON.stringify(options.threshold ?? 0); const done = useRef(false); useEffect(() => { const el = ref.current; if (!el || typeof IntersectionObserver === 'undefined') return; if (once && done.current) return; const pool = poolFor({ root, rootMargin, threshold: JSON.parse(thresholdKey) as number | number[], }); const handle = (entry: IntersectionObserverEntry) => { setState({ inView: entry.isIntersecting, ratio: entry.intersectionRatio }); if (once && entry.isIntersecting) { done.current = true; pool.handlers.delete(el); pool.observer.unobserve(el); } }; pool.handlers.set(el, handle); pool.observer.observe(el); return () => { pool.handlers.delete(el); pool.observer.unobserve(el); }; }, [ref, root, rootMargin, thresholdKey, once]); return state;}
'use client';import { useEffect, useRef } from 'react';/* ========================================================================== One shared requestAnimationFrame loop. -------------------------------------------------------------------------- Forty components each calling requestAnimationFrame means forty callbacks the browser schedules separately and forty chances to read layout after a write. One loop, one ordered pass, one place to clamp delta time. Delta is clamped to 100 ms. Without that, returning to a backgrounded tab delivers a multi-second dt and every physics integration in the page explodes on the first frame back. ========================================================================== */export type RafCallback = (dt: number, elapsed: number) => void;const callbacks = new Set<RafCallback>();let frame = 0;let last = 0;let start = 0;const MAX_DT = 0.1;const DEFAULT_DT = 1 / 60;function tick(now: number) { frame = requestAnimationFrame(tick); const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT); last = now; const elapsed = (now - start) / 1000; // Copy: a callback may unsubscribe itself (or another) mid-iteration. for (const cb of Array.from(callbacks)) { if (callbacks.has(cb)) cb(dt, elapsed); }}function subscribe(cb: RafCallback) { callbacks.add(cb); if (frame === 0) { start = performance.now(); last = 0; frame = requestAnimationFrame(tick); } return () => { callbacks.delete(cb); if (callbacks.size === 0 && frame !== 0) { cancelAnimationFrame(frame); frame = 0; last = 0; } };}/** * Run `cb` every frame while `enabled`. * * The callback is held in a ref, so it does not need to be memoised and the * subscription is never torn down just because the closure changed identity. */export function useRaf(cb: RafCallback, enabled = true): void { const ref = useRef(cb); ref.current = cb; useEffect(() => { if (!enabled) return; return subscribe((dt, elapsed) => ref.current(dt, elapsed)); }, [enabled]);}
'use client';import { useSyncExternalStore } from 'react';/* ========================================================================== prefers-reduced-motion, SSR-safe. -------------------------------------------------------------------------- useSyncExternalStore rather than useState + useEffect: the server snapshot is an explicit `false`, so the markup React renders on the server and the markup it hydrates with agree, and the real value lands in the same commit as hydration instead of one frame later. A useEffect version flashes the animated variant before removing it — the exact thing the user asked not to see. Reduced motion is not "duration: 0". For most components here it means a different render: the final state, a static poster, or a paused frame. ========================================================================== */const QUERY = '(prefers-reduced-motion: reduce)';function subscribe(onChange: () => void) { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(QUERY); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange);}function getSnapshot() { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(QUERY).matches;}function getServerSnapshot() { return false;}export function useReducedMotion(): boolean { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);}/** Generic media-query hook, same SSR guarantees. */export function useMediaQuery(query: string): boolean { return useSyncExternalStore( (onChange) => { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(query); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, () => { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(query).matches; }, () => false, );}
'use client';import { useEffect, useState, type RefObject } from 'react';/* ========================================================================== Element size, from one shared ResizeObserver. -------------------------------------------------------------------------- Sized from the element rather than the window because a preview in a grid cell resizes when the grid reflows, not when the viewport does. Reading `window.innerWidth` is the reason so many canvas components are correct on load and wrong after a sidebar opens. ========================================================================== */export interface Size { width: number; height: number;}type Handler = (size: Size) => void;let observer: ResizeObserver | null = null;const handlers = new Map<Element, Handler>();function ensureObserver(): ResizeObserver | null { if (typeof ResizeObserver === 'undefined') return null; observer ??= new ResizeObserver((entries) => { for (const entry of entries) { const box = entry.contentBoxSize?.[0]; const size: Size = box ? { width: box.inlineSize, height: box.blockSize } : { width: entry.contentRect.width, height: entry.contentRect.height }; handlers.get(entry.target)?.(size); } }); return observer;}export function useResizeObserver(ref: RefObject<Element | null>): Size { const [size, setSize] = useState<Size>({ width: 0, height: 0 }); useEffect(() => { const el = ref.current; const ro = ensureObserver(); if (!el || !ro) return; // Seed synchronously: waiting for the first observer callback leaves one // frame where a canvas is sized 0x0, which some drivers log as an error. const rect = el.getBoundingClientRect(); setSize({ width: rect.width, height: rect.height }); handlers.set(el, (next) => setSize((prev) => prev.width === next.width && prev.height === next.height ? prev : next, ), ); ro.observe(el); return () => { handlers.delete(el); ro.unobserve(el); }; }, [ref]); return size;}
'use client';import { useRef } from 'react';import { useRaf } from '@/hooks/use-raf';import { clamp, damp } from '@/lib/damp';/* ========================================================================== Smoothed scroll velocity. -------------------------------------------------------------------------- Raw `scrollY` deltas are spiky: a trackpad emits bursts, a mouse wheel emits steps, and either one drives a geometry parameter into visible stutter. Two stages fix it — a one-pole low-pass on the delta itself, then a damped approach toward that filtered value, which is what produces the free ease-out when scrolling stops. Values are exposed through a ref, not state. A velocity that changes every frame must never re-render React. ========================================================================== */export interface ScrollVelocity { /** Signed, in pixels per second, smoothed. */ velocity: number; /** `velocity` normalised to roughly -1..1 for driving visual parameters. */ normalized: number; /** -1 up, 1 down, 0 at rest. */ direction: number; /** Current scroll offset in pixels. */ scroll: number;}export interface UseScrollVelocityOptions { /** Velocity that maps to `normalized === 1`. Default 1500 px/s. */ reference?: number; /** Fraction of the gap remaining after 1 s. Lower is snappier. */ smoothing?: number; /** Read from this element instead of the window. */ target?: HTMLElement | null;}export function useScrollVelocity( options: UseScrollVelocityOptions = {},): { current: ScrollVelocity } { const { reference = 1500, smoothing = 0.0001, target = null } = options; const state = useRef<ScrollVelocity>({ velocity: 0, normalized: 0, direction: 0, scroll: 0, }); const last = useRef<number | null>(null); const raw = useRef(0); useRaf((dt) => { if (typeof window === 'undefined' || dt <= 0) return; const scroll = target ? target.scrollTop : window.scrollY; const previous = last.current; last.current = scroll; // First frame has no previous sample; a delta against 0 would read as a // full-page jump on any deep link. if (previous === null) { state.current.scroll = scroll; return; } const instant = (scroll - previous) / dt; // Stage 1: low-pass the instantaneous value so wheel steps stop ringing. raw.current = damp(raw.current, instant, 0.01, dt); // Stage 2: the value consumers read, softer again. const velocity = damp(state.current.velocity, raw.current, smoothing, dt); state.current = { velocity, normalized: clamp(velocity / reference, -1, 1), direction: Math.abs(velocity) < 1 ? 0 : Math.sign(velocity), scroll, }; }); return state;}
'use client';import { useSyncExternalStore } from 'react';/* ========================================================================== Document visibility, shared. -------------------------------------------------------------------------- A backgrounded tab still runs `setInterval`, still holds WebGL contexts, and — on some platforms — still gets throttled rAF ticks with multi-second deltas. Anything expensive should stop when the tab is hidden and resume when it comes back, rather than trying to catch up. `useSyncExternalStore` with an explicit server snapshot so SSR markup and the first client render agree. ========================================================================== */function subscribe(onChange: () => void): () => void { document.addEventListener('visibilitychange', onChange); return () => document.removeEventListener('visibilitychange', onChange);}function getSnapshot(): boolean { return document.visibilityState === 'visible';}/** Assume visible on the server: the alternative flashes a paused state. */function getServerSnapshot(): boolean { return true;}export function useVisibility(): boolean { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);}
import type * as THREE from 'three';/* ========================================================================== WebGL context lifecycle. -------------------------------------------------------------------------- Chromium allows roughly 16 live WebGL contexts per RENDERER PROCESS (8 on low-end devices). Creating the 17th does not fail — it fires `webglcontextlost` on the OLDEST one, so an unrelated component elsewhere on the page goes black. Same-origin iframes share the renderer process, so isolating previews in iframes does not raise the ceiling. The only real fix is to release contexts on unmount, and `renderer.dispose()` ALONE DOES NOT DO THAT. It frees GPU objects but leaves the context itself alive until GC decides otherwise, which may be never. `forceContextLoss()` is the call that actually hands it back. ========================================================================== *//** * Fully release a three.js renderer. Call this in the `useEffect` cleanup of * any component that constructs a `WebGLRenderer` — the preview host drives * unmount via `<Activity mode="hidden">`, so ordinary cleanup IS the disposal * path and nothing component-specific is required. */export function disposeRenderer(renderer: THREE.WebGLRenderer | null | undefined): void { if (!renderer) return; // Stop the loop first: a queued frame that renders into a lost context // throws, and the throw escapes the cleanup function. renderer.setAnimationLoop(null); renderer.dispose(); renderer.forceContextLoss(); const canvas = renderer.domElement; if (canvas) { // Zeroing the backing store releases the drawing buffer immediately // instead of holding width×height×4 bytes until the node is collected. canvas.width = 0; canvas.height = 0; }}/** * Release a raw WebGL context (ogl, or hand-written GL). The extension is the * only portable way to do it; it is absent in some headless configurations, * hence the optional call. */export function loseContext(gl: WebGLRenderingContext | WebGL2RenderingContext | null | undefined): void { if (!gl) return; gl.getExtension('WEBGL_lose_context')?.loseContext();}/** Recursively dispose geometries, materials and textures under a root. */export function disposeObject(root: THREE.Object3D | null | undefined): void { if (!root) return; root.traverse((obj) => { const mesh = obj as THREE.Mesh; mesh.geometry?.dispose?.(); const material = mesh.material; if (!material) return; const materials = Array.isArray(material) ? material : [material]; for (const m of materials) { for (const value of Object.values(m as unknown as Record<string, unknown>)) { if (value && typeof value === 'object' && 'isTexture' in value) { (value as THREE.Texture).dispose(); } } m.dispose?.(); } });}/** * Device pixel ratio capped for fill-rate. Above 2 the visual gain is nil and * the cost is quadratic; on a 3x phone an uncapped full-screen shader is the * single most common cause of a janky first paint. */export function pixelRatio(max = 2): number { if (typeof window === 'undefined') return 1; return Math.min(window.devicePixelRatio || 1, max);}/** * Whether WebGL is actually available. Some browsers expose the constructor * but fail to create a context (blocklisted GPU, no hardware acceleration); * a component that assumes success renders a permanently blank box. */export function hasWebGL(): boolean { if (typeof document === 'undefined') return false; try { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl'); if (!gl) return false; (gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext(); return true; } catch { return false; }}
05
API
Every parameter, its type and what happens when you change it.
Element whose scrollTop drives the wave. null reads window.scrollY.
amplitude
number
= 0.07
Depth of the ripple in world units at full deflection.
shear
number
= 0.04
Vertical shear, which breaks up the horizontal banding.
frequency
number
= 3
Spatial frequency of the sine, in radians per world unit.
phase
number
= 100
Phase offset, so the wave is not symmetric about the plane centre.
maxDeform
number
= 1.5
Clamp on the signed deform scalar.
reference
number
= 900
Pixels per second of scroll that count as full deflection.
shade
number
= 0.22
How far the slope term brightens crests and darkens troughs.
overscan
number
= 1.12
Crop held in reserve so displaced vertices never sample past the edge.
segments
number
= 96
Plane subdivisions per axis. The resolution of the wave.
paint
(ctx, size) => void
—
Override the image. Receives a 2D context for a 1024x1024 canvas.
className
string
—
Merged onto the wrapper via cn.
06
How it works
The technique in order, not the code in order.
01
The depth term is pos.z += sin(position.y * frequency + phase) * deform * amplitude, and the shear term is pos.y += sin(position.x * frequency) * deform * shear. Both are sampled from position — the original flat attribute — not from the running pos.
02
That distinction is the whole technique. Sampling position fixes the wave to the picture, so a given pixel always sits at the same point on the sine and the image appears to flex. Sampling the already-displaced pos would make each term feed the next, so the wave travels through the surface and reads as a glitch.
03
The phase constant breaks the symmetry. Without it, sin(y * f) is odd about the plane's centre line and the top and bottom halves mirror each other exactly, which looks like a crease rather than a wave.
04
The slope, not the height, drives the shading: vBend = cos(position.y * frequency + phase) * deform is the analytic derivative of the depth term, so crests brighten and troughs darken with one cosine and no recomputed vertex normals.
05
useScrollVelocity supplies a two-stage smoothed signal — a low-pass on the raw delta, then a damped approach — and the component damps once more on top of it with a 0.0025 smoothing factor, so the ripple eases out for a beat after the scroll stops instead of snapping flat.
06
The fragment shader cover-fits the source into the quad and divides the sampled window by an overscan factor, which is what keeps the displaced vertices from sampling past the texture edge at the crest of every wave.
Load-bearing constants
Load-bearing constants
Constant
Value
Why this number
amplitude
= 0.07
World units of depth at full deflection, against a plane roughly 2.5 units tall. Below ~0.02 the ripple is lost in the perspective foreshortening; above ~0.2 the crests swing far enough toward the camera to cross the near plane and punch holes in the image.
shear
= 0.04
Deliberately about half the amplitude. Its only job is to break the perfectly horizontal banding so the surface reads as cloth; raise it toward the amplitude and the two waves beat against each other into a visible moire.
frequency
= 3
Radians per world unit, so roughly one and a half crests across the visible plane. Below ~1.5 there is less than a full wave on screen and the image just tilts; above ~8 the crests fall closer together than the vertex spacing and the wave aliases into flicker.
phase
= 100
An arbitrary large offset whose only purpose is to be nowhere near a multiple of pi. At phase 0 the sine is odd about the plane's centre, the halves mirror, and the ripple looks like a fold down the middle.
maxDeform
= 1.5
Ceiling on the signed scalar, so a trackpad flick cannot drive the displacement past the point where the surface self-intersects. Everything downstream multiplies by this, so it is the one number that bounds the entire effect.
reference
= 900 px/s
Scroll speed that counts as full deflection. The hook defaults to 1500, which is tuned for whole-page parallax; a component-sized scroller rarely reaches it, so the wave would never appear.
DEFORM_SMOOTHING
= 0.0025
Fraction of the gap remaining after one second. Slower and the wave lags visibly behind the scroll; much faster and it snaps flat the instant scrolling stops, losing the ease-out that makes it feel like momentum.
overscan
= 1.12
Twelve percent crop held in reserve. At exactly 1.0 the displaced vertices sample past the edge of the texture at every crest and the clamped border pixel smears across the top and bottom of the image.
segments
= 96
The wave is evaluated per vertex, so subdivision is its resolution. Below ~32 the sine renders as a polyline and the shading term steps in visible bands; above ~160 the vertices land inside a single pixel and only cost transform time.
shade
= 0.22
How far the slope term swings brightness. At 0 the ripple is nearly invisible on a busy image; above ~0.5 the crests blow out to white and read as a lighting bug rather than as a surface.
07
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The deform scalar is forced to 0 regardless of scroll velocity, and it is still damped there, so a component that is mid-ripple when the preference changes eases flat rather than snapping. The image then stays perfectly still while scrolling — the cover fit and the resize handling continue to work, but no vertex is ever displaced and no shading swing occurs.
Cleanup
Removes the mesh from the scene, disposes the CanvasTexture by hand because it lives inside material.uniforms where disposeObject does not look, runs disposeObject over the scene, disposes the geometry and the material, then disposeRenderer for setAnimationLoop(null) / dispose() / forceContextLoss(), and removes the canvas element. The stage ref is nulled first so a queued frame is a no-op. useScrollVelocity unsubscribes with the shared RAF loop it was registered on.
Definition of done
Scroll the container and the image ripples in horizontal bands, with the crests catching a little more light than the troughs. Scroll the other way and the bands bow the opposite direction. Stop and the surface eases flat over about half a second rather than snapping. Flick hard and the ripple deepens but never folds through itself or shows a smeared edge. The image keeps its own proportions at any container aspect, and scrolling the page while the component is off screen costs nothing because the loop is gated on visibility. With prefers-reduced-motion: reduce the image is completely still at every scroll speed.
Pitfalls
Sampling the wave from the running pos instead of the original position. Each term then feeds the next, the ripple travels through the surface as it deforms rather than staying pinned to the image, and the effect looks like a rendering fault instead of the photograph flexing.
Taking the magnitude of the velocity. abs(velocity) makes scrolling up and scrolling down produce an identical wave, which throws away the direction cue that carries most of the effect. The scalar stays signed all the way into the uniform.
Using a phase of 0. sin(y * f) is odd about the plane's centre, so the top and bottom halves mirror each other exactly and the ripple reads as a crease down the middle rather than a wave.
Feeding raw scroll deltas into the uniform. A wheel emits discrete steps and a trackpad emits bursts; either drives the geometry into visible stutter. The signal is low-passed, damped, and damped again before it reaches the shader.
Leaving frustumCulled on. The bounding sphere was computed for the flat plane, so when the component sits at the edge of the viewport three culls it against geometry the shader has already moved and the image blinks out mid-ripple.
Fitting the plane with window.innerWidth. The plane has to cover the camera's frustum at z = 0, which is 2 * tan(fov/2) * distance tall — a pixel width has no fixed relationship to that, so the image ends up letterboxed or overscaled depending on the viewport.
Skipping the overscan. Displaced vertices sample outside [0,1] at the crest of every wave, and the texture's clamped border smears along the top and bottom edges exactly when the wave is at its most visible.
Passing a ref to scrollTarget. The hook takes an HTMLElement | null, not a ref object, so a scroller inside the component has to be captured with a callback ref into state before it can be handed over — a plain ref would still be null on the render that matters.
Calling renderer.dispose() alone. That frees GPU objects and leaves the context alive; a page with several WebGL components then hits Chromium's per-process cap and the oldest canvases go blank. disposeRenderer calls forceContextLoss.
Notes
scrollTarget defaults to null, which reads window.scrollY. Pass the scrolling element when the component lives inside its own overflow-y-auto container, as the demo does.
The plane is a unit quad rescaled on resize rather than rebuilt, so a container resize never reallocates a 96x96 vertex buffer.