Building that liquid ripple shader in React Three Fiber
Nothing humbles you faster than a WebGL canvas that just decides to stay black. No errors in the console. No warnings. Just an empty void staring back at you while your deadline creeps closer.
I spent most of last Tuesday fighting with exactly this. A client wanted that trendy, interactive liquid ripple effect over their hero typography. You know the one. You move your mouse across the screen, and the text distorts like you’re dragging your finger through a pool of water. It looks incredible when done right. It’s also an absolute headache to build from scratch if you don’t map out the logic first.
I finally got it working perfectly using Three.js and React Three Fiber (R3F), wrapped in some Tailwind for the layout. But the process wasn’t pretty. Here is what actually works, minus the three hours I wasted going down the wrong path.
The core illusion
My first instinct was completely wrong. I thought I needed to generate a massive, high-density plane geometry and physically displace the vertices using a raycaster. Don’t do this. I tried it, and my laptop’s fans immediately sounded like a jet engine taking off.
The trick is entirely 2D. It’s a post-processing illusion.
You aren’t bending 3D space. You’re just taking a flat image—or a rendered plane with your UI on it—and messing with how the pixels are mapped to the screen. You track the mouse, calculate its velocity, and draw soft white circles onto a hidden black canvas whenever the mouse moves. That hidden canvas acts as a “height map” or a displacement map.
Then, you pass that map into a custom fragment shader. The shader looks at the grayscale values. If a pixel on the hidden canvas is white, the shader shifts the UV coordinates of your main image slightly. If it’s black, it leaves them alone. That’s it. Math soup that tricks the brain into seeing water.
Setting up the render target
import { useRef, useMemo } from 'react'
import { useFrame, useThree } from '@react-three/fiber'
import * as THREE from 'three'
export function RippleDisplacement() {
const { gl, size } = useThree()
// Create our hidden render target
const renderTarget = useMemo(() => new THREE.WebGLRenderTarget(size.width, size.height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
type: THREE.FloatType,
}), [size])
const shaderRef = useRef()
const mousePos = useRef(new THREE.Vector2())
const velocity = useRef(0)
useFrame((state) => {
// Calculate mouse speed for the ripple intensity
const currentPos = state.pointer
const dist = currentPos.distanceTo(mousePos.current)
velocity.current = THREE.MathUtils.lerp(velocity.current, dist * 0.1, 0.1)
// Update previous position
mousePos.current.copy(currentPos)
if (shaderRef.current) {
shaderRef.current.uniforms.uDisplacement.value = renderTarget.texture
shaderRef.current.uniforms.uTime.value = state.clock.elapsedTime
}
})
return (
<mesh>
<planeGeometry args={[size.width, size.height]} />
<rippleShaderMaterial ref={shaderRef} />
</mesh>
)
}
The fragment shader
varying vec2 vUv;
uniform sampler2D uMainTexture;
uniform sampler2D uDisplacement;
uniform float uTime;
void main() {
// Read the hidden mouse trail canvas
vec4 dispMap = texture2D(uDisplacement, vUv);
// Calculate how much to bend the pixels
// Multiplying by 0.05 controls the maximum wave height
vec2 distortedUv = vUv + (dispMap.rg - 0.5) * 0.05;
// Sample the actual image using the bent coordinates
vec4 finalColor = texture2D(uMainTexture, distortedUv);
// Optional: add a tiny bit of fake specular highlight based on the wave
float highlight = dispMap.r * 0.1;
finalColor.rgb += vec3(highlight);
gl_FragColor = finalColor;
}
Don’t skip that cleanup step. Seriously.
Once the memory leak was plugged, the effect ran at a locked 60fps even on a heavy DOM. I ended up mapping the client’s main H1 text to an HTML-to-canvas texture, passing that as the uMainTexture, and the result was incredibly satisfying. You drag your mouse across the typography, and it pulls and snaps back like a physical fluid.
You can push this a lot further, obviously. Add a chromatic aberration pass to the edges of the ripples, or tweak the decay rate of the hidden canvas so the “water” takes longer to settle. But the core mechanic is just UV distortion. Learn to love the math soup.
