Back to all blogs
#frontend
#webgl
#three.js
#shaders
December 22, 2025
5 min read
A blog post byThereallo

Reverse Engineering Google Flow's Hero Effect

Recreating the WebGL blur effect from Flow using Three.js

labs.google/flow has a hero section that makes you feel like you're touching liquid glass.
The word "Flow" sits there, and as you move your mouse, the letters smear and blur like ink in water. Stop moving, and it drifts on its own in lazy circles.
I opened DevTools and did a bit of digging.

My first instinct was wrong. I assumed this was some fancy CSS filter trick or maybe an SVG turbulence effect. Nope.
Buried in the webpack bundle, I found the telltale signs:
javascript
new THREE.WebGLRenderer({
    canvas: glCanvas,
    alpha: true,
    antialias: true
});
Of course it's Three.js.
But this wasn't some 3D scene with geometries and lights. They're using Three.js as a shader delivery mechanism. The entire effect is two full-screen quads with custom GLSL shaders doing all the heavy lifting.

The blur isn't a simple box blur or even a single Gaussian pass.
Google is running two separate render passes: horizontal blur first, then vertical blur.
javascript
// pass 1: Horizontal blur to render target
renderer.setRenderTarget(renderTarget1);
renderer.render(scene1, camera);

// pass 2: Vertical blur to screen
renderer.setRenderTarget(null);
renderer.render(scene2, camera);
This is a classic optimization. A 2D Gaussian blur requires sampling in a grid pattern (very expensive). But if you separate it into two 1D passes, you get the same result with far fewer texture samples. Graphics programmers figured this out decades ago, but seeing it in a marketing page felt like overkill.
Beautifully overkill...?

I extracted both fragment shaders from the minified bundle. They were mangled, but the structure was intact. Here's the core of what's happening:
glsl
float blurFactor = 1.0 - smoothstep(innerRadius, outerRadius, dist);

for(int i = -20; i <= 20; i++) {
    float x = float(i);
    float weight = gaussian(x, sigma);
    
    vec2 velocityOffset = velocityDir * x * velocityStrength;
    vec2 offset = vec2(x * 0.001, 0.0) + velocityOffset;
    
    vec2 sampleUV = clampUV(uv + offset);
    sum += texture2D(uTexture, sampleUV) * weight;
    totalWeight += weight;
}
They're sampling 41 points along a line (-20 to +20), weighting each sample with a Gaussian curve, and accumulating the result. The velocityOffset is the secret sauce, it stretches the blur in the direction you're moving.
Move your mouse fast, and the text smears like it's being dragged through honey.

My first recreation looked correct for about 3 seconds. Then I moved my mouse, and the blur appeared in the wrong place. Then I resized the window, and everything got messed up.
The problem is that:
Three.js thinks in pixels. GLSL thinks in UV coordinates (0 to 1). The mouse thinks in screen space. The canvas thinks in device pixels.
I had to convert mouse position to canvas-relative coordinates, then multiply by the device pixel ratio, then pass it to the shader, which divides by the resolution to get back to UV space.
javascript
function getRelativePosition(event, canvas) {
    const rect = canvas.getBoundingClientRect();
    const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
    return {
        x: (event.clientX - rect.left) * pixelRatio,
        y: (event.clientY - rect.top) * pixelRatio
    };
}
And because WebGL has the Y-axis flipped compared to the DOM:
javascript
const clampedY = clamp(glCanvas.height - pos.y, blurBoundary, glCanvas.height - blurBoundary);
I mass mass mass coordinate bugs. Hours of staring at numbers that should have been obvious.

If you leave the page alone, the blur effect doesn't stop. It starts drifting in an elliptical pattern, think screensavers from 2003.
Google tracks mouse activity with a timeout:
javascript
function resetIdleTimer() {
    isIdle = false;
    if (idleTimeout) clearTimeout(idleTimeout);
    idleTimeout = setTimeout(() => {
        isIdle = true;
        targetMouse.set(glCanvas.width / 2, glCanvas.height / 2);
    }, 5000);
}
After 5 seconds of no input, the effect takes over:
glsl
if (isIdle) {
    idleAngle += 15 * deltaTime;
    const radius = 0.4 * Math.min(glCanvas.width, glCanvas.height);
    
    targetMouse.set(
        centerX + Math.cos(idleAngle) * radius,
        centerY - Math.sin(idleAngle) * radius / 2
    );
}
The /2 on the Y-axis creates an ellipse instead of a circle. It's subtle, but it makes the motion feel more organic.

Moving the blur directly to the mouse position looked jittery. Google smooths everything with exponential interpolation:
javascript
currentMouse.x += (targetMouse.x - currentMouse.x) * 0.02;
currentMouse.y += (targetMouse.y - currentMouse.y) * 0.02;
That 0.02 means the blur only moves 2% of the remaining distance each frame. It creates a trailing, elastic feel.
They also track velocity with a 10-frame history buffer:
javascript
velocityHistory.push(instantVelocity);
if (velocityHistory.length > 10) velocityHistory.shift();

const avgVelocity = new THREE.Vector2(0, 0);
for (const v of velocityHistory) {
    avgVelocity.add(v);
}
avgVelocity.divideScalar(velocityHistory.length);
This prevents the blur from snapping instantly when you start or stop moving. The effect eases in and out of motion blur.

The final piece was the blend mode. The blur has a subtle glow that makes the white text pop against the video background.
glsl
vec4 plusLighter(vec4 source, vec4 destination) {
    source.rgb *= uAlpha;
    destination.a = 1.0;
    vec4 finalColor = source + destination * (1.0 - source.a);
    finalColor.a = finalColor.r * 1.0;
    return finalColor;
}
This is a custom blend function that adds the blurred color on top of the original, creating that luminous halo effect. The uAlpha uniform (set to 1.2) pushes the whites slightly into HDR territory.

After a few hours of staring at GLSL errors and coordinate transforms, I had a working recreation.

Flow Demo

0:00 / 0:00
Google spent engineering hours building a blur effect for four letters. It doesn't help you sign up. It doesn't explain the product. It just... vibes.
My kind of vibes.

P.S. - Google, if you're reading this, please don't sue me. I just think your blur is neat.