← All articles
WebGL Displacement Hover Effect (Image Shader Guide)
Build the signature WebGL displacement hover effect with a GLSL image displacement shader in React Three Fiber — uniforms, lerp, performance, and a reduced-motion fallback.

The melting, liquid-glass image transition you see on award-winning sites is almost always the same trick: a WebGL displacement hover effect driven by a single GLSL image displacement shader. You feed the GPU two textures — the image and a grayscale "displacement map" — then push pixels around based on the map's brightness, animating one progress uniform from 0 to 1 on hover. That's the whole illusion. No video, no sprite sheet, no 200-frame export.
I've shipped this effect on production sites that picked up Awwwards recognition, and the gap between "looks cheap" and "looks expensive" is entirely in the details: the lerp, the aspect-ratio math, and respecting users who don't want motion. This guide walks the real implementation in React Three Fiber, with code you can paste.
What a displacement shader actually does
A fragment shader runs once per pixel. For each pixel it asks: which texel of the source image should I sample? Normally you sample the UV coordinate straight (texture2D(uImage, vUv)). A displacement effect offsets that UV by an amount read from a second texture before sampling:
float strength = texture2D(uDisp, vUv).r; // 0..1 from a grayscale map
vec2 distortedUv = vUv + strength * uIntensity;
vec4 color = texture2D(uImage, distortedUv);
Where the displacement map is white, pixels get yanked far; where it's black, they stay put. Animate uIntensity toward zero as the image settles and the picture "reassembles" from chaos. Swap the map (ink ripples, perlin noise, brush strokes, liquid) and you get an entirely different signature with the same ~30 lines of code.
The displacement map is the art direction. A radial gradient gives a clean zoom-warp; a smudgy noise texture gives the liquid-ink look most people recognise.
The GLSL fragment shader
Here's a minimal but production-shaped fragment shader that cross-fades between two images and warps them in opposite directions — the classic hover transition where one picture dissolves into the next:
precision highp float;
uniform sampler2D uTexture1;
uniform sampler2D uTexture2;
uniform sampler2D uDisp;
uniform float uProgress; // 0 = image1, 1 = image2
uniform float uIntensity; // ~0.3 feels right
varying vec2 vUv;
void main() {
float disp = texture2D(uDisp, vUv).r;
// Push the outgoing image one way, the incoming image the other.
vec2 uv1 = vUv + uProgress * disp * uIntensity * vec2(1.0, 0.0);
vec2 uv2 = vUv - (1.0 - uProgress) * disp * uIntensity * vec2(1.0, 0.0);
vec4 c1 = texture2D(uTexture1, uv1);
vec4 c2 = texture2D(uTexture2, uv2);
gl_FragColor = mix(c1, c2, uProgress);
}
The vertex shader is the trivial one — just pass UVs through:
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Two gotchas that bite everyone:
- Aspect ratio. A plane's UVs are 0–1, but your image is rarely square. Sampling raw UVs stretches the picture. Either cover-fit the UVs in the shader with a
uResolution/uImageResolutionratio, or size the plane to the image's aspect so 1:1 UVs already map correctly. The plane-sizing route is simpler and what I reach for first. - Texture wrapping. When
distortedUvexceeds 0–1, the GPU wraps by default and you get a smeared edge. Settexture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping(or keep intensity low enough that UVs stay in range).
Wiring it in React Three Fiber
drei's shaderMaterial is the cleanest way to declare uniforms once and get a typed, JSX-friendly material. Its signature is shaderMaterial(uniforms, vertexShader, fragmentShader), and every uniform becomes a settable property on the instance — perfect for animating from useFrame.
import * as THREE from "three";
import { useRef } from "react";
import { Canvas, useFrame, extend } from "@react-three/fiber";
import { shaderMaterial, useTexture } from "@react-three/drei";
const DisplaceMaterial = shaderMaterial(
{
uTexture1: null,
uTexture2: null,
uDisp: null,
uProgress: 0,
uIntensity: 0.3,
},
vertexShader, // the strings above
fragmentShader,
);
extend({ DisplaceMaterial });
function ImagePlane({ hovered }: { hovered: boolean }) {
const ref = useRef<any>(null);
const [t1, t2, disp] = useTexture([
"/img/a.jpg",
"/img/b.jpg",
"/img/displacement.jpg",
]);
useFrame((_, delta) => {
if (!ref.current) return;
const target = hovered ? 1 : 0;
// frame-rate-independent lerp toward the target
ref.current.uProgress = THREE.MathUtils.damp(
ref.current.uProgress,
target,
6, // smoothing factor — higher = snappier
delta,
);
});
return (
<mesh>
<planeGeometry args={[1.6, 1, 1, 1]} />
<displaceMaterial
ref={ref}
uTexture1={t1}
uTexture2={t2}
uDisp={disp}
/>
</mesh>
);
}
The hover state itself comes from R3F's pointer events (onPointerOver/onPointerOut on the mesh) or a plain DOM wrapper around the <Canvas>. I prefer a DOM wrapper: hit-testing a full-bleed div is cheaper and more reliable than raycasting a single plane.
The lerp is the whole effect
Beginners set uProgress directly to 1 on hover and wonder why it looks like a slideshow. The magic is easing the uniform, not snapping it. Two solid options:
THREE.MathUtils.damp(above) — frame-rate independent, no dependency, naturally eased. My default.- GSAP —
gsap.to(material.uniforms.uProgress, { value: 1, duration: 0.8, ease: "power3.out" }). Reach for this when you want a specific named ease or to chain the displacement into a larger timeline. If you're weighing animation libraries, see GSAP vs Framer Motion.
Avoid a naive value += 0.05 per frame: it runs at different speeds on a 60Hz vs 120Hz display. Always factor in delta or use a damp/GSAP that already does.
Performance notes from production
This effect is GPU-cheap but easy to make heavy. What actually matters:
| Decision | Cheap & sharp | Expensive / janky |
|---|---|---|
| Texture size | Source ~2× display px, compressed | Full 4K JPEGs |
| Texture format | KTX2 / Basis (GPU-compressed) | Decoded PNG/JPEG in VRAM |
dpr | Clamp: dpr={[1, 2]} | Uncapped on 3× phones |
| Render loop | frameloop="demand" + invalidate, or pause offscreen | Always rendering every plane |
| Color management | texture.colorSpace = THREE.SRGBColorSpace | Washed-out / wrong gamma |
Three rules I never break:
- Set
colorSpaceon every image texture or your images look milky. R3F/drei mostly handle this now, but verify. - Clamp
dpr— a[1, 2]cap on the<Canvas>is the single biggest perf win on phones. There's no visible benefit to rendering a hover effect at 3× density. - Don't render planes that aren't visible. Use an
IntersectionObserverto mount/animate only on-screen images, and stop the loop on hover-out onceuProgresshas settled. The same discipline that keeps smooth scroll with GSAP & Lenis at 60fps applies here.
On the Iventions events site case study the displacement transitions ran alongside a full GSAP timeline and Three.js scene, and staying inside frame budget came down to exactly these choices — compressed textures and a demand-driven loop, not a faster GPU.
prefers-reduced-motion fallback (non-negotiable)
A displacement warp is vestibular motion. Users who set prefers-reduced-motion: reduce should get a clean cross-fade — or no transition at all — instead of a churning image. This is both an accessibility requirement and, increasingly, a ranking-adjacent quality signal.
import { useReducedMotion } from "@react-three/drei"; // or a tiny custom hook
function ImagePlane({ hovered }: { hovered: boolean }) {
const reduce = useReducedMotion();
useFrame((_, delta) => {
if (!ref.current) return;
const target = hovered ? 1 : 0;
if (reduce) {
// Snap with intensity 0 → it becomes a plain opacity cross-fade.
ref.current.uIntensity = 0;
ref.current.uProgress = target;
} else {
ref.current.uIntensity = 0.3;
ref.current.uProgress = THREE.MathUtils.damp(
ref.current.uProgress, target, 6, delta,
);
}
});
}
If you don't want React Three Fiber's hook, read the media query directly:
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
Setting uIntensity to 0 keeps the exact same shader path — the mix() still cross-fades — so you maintain one code path instead of branching into a separate DOM fallback.
FAQ
Do I need React Three Fiber, or can I do this in raw three.js / WebGL?
You can do it in raw three.js or even hand-written WebGL — the shader is identical. R3F just removes the boilerplate of scene/camera/render-loop wiring and gives you useFrame + shaderMaterial for clean uniform updates. For a single hero image, raw three.js is fine; for a gallery of interactive planes, R3F's component model pays off fast.
Where do I get a displacement map?
Any grayscale image works: a radial gradient (Photoshop/Figma), a cloud/noise render, an ink-in-water photo desaturated, or a procedural perlin/simplex pattern generated in the shader itself. Black = no movement, white = maximum movement. The character of the map is the character of your transition — it's worth art-directing rather than grabbing the first one you find.
Why does my image look stretched or washed out?
Stretched = an aspect-ratio mismatch between the plane's 0–1 UVs and a non-square image; fix it by sizing the plane to the image aspect or correcting UVs in the shader. Washed out = missing sRGB color space; set texture.colorSpace = THREE.SRGBColorSpace.
Is the displacement effect bad for performance or SEO?
Not if you compress textures (KTX2), clamp dpr, and only render visible/animating planes. The image itself should still ship as a real <img> for SEO/LCP, with the WebGL canvas layered on top progressively — so crawlers and no-JS users get the picture and the effect is an enhancement.
This same displacement core scales from a single hover thumbnail to full-page WebGL image transitions between routes. If you want this signature effect built into your site — performant, accessible, and Awwwards-grade — see how I work with clients and studios.