Tae7labs Logo
Tae7labs
SinghAshir65848
@SinghAshir65848
/March 3, 2026/Intermediate

Water Ripple Hover Effect

A tutorial rebuilding a mesmerizing water ripple effect that distorts text on hover, creating a fluid, organic interaction using Three.js, React, and custom GLSL shaders.

Water Ripple Hover Effect  autoplaying demo

Initializing the project

Let's start the project by creating a Next.js application. We can do that by running `npx create-next-app@latest client` inside of a terminal.

We can delete everything in the `page.js`, `global.css` and `page.module.css` and add our own HTML and CSS, to start with a nice blank application.

We will use Sass for the stylesheets, so we can run `npm i sass`.

We will use Three.js for the animation, so we can run `npm i three`.

Root Layout Configuration

The main layout component will be placed at the root level so it's shared among all pages.

app/layout.jsx
import { Inter } from 'next/font/google'
import './globals.css'
import RippleEffect from '../components/RippleEffect';

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Water Ripple Effect',
  description: 'Generated by create next app',
}

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <RippleEffect />
        {children}
      </body>
    </html>
  )
}

Ripple Effect Component Structure

We'll create a dedicated component for the ripple effect. Inside the `components` folder, create a new folder named `RippleEffect` with the following files: `index.jsx`, `style.module.scss`, and `shaders.js`.

components/RippleEffect/index.jsx
'use client';

import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import {
  simulationVertexShader,
  simulationFragmentShader,
  renderVertexShader,
  renderFragmentShader,
} from './shaders';
import styles from './style.module.scss';

export default function RippleEffect() {
  const containerRef = useRef(null);
  const canvasRef = useRef(null);
  const mouseRef = useRef(new THREE.Vector2(0, 0));

  useEffect(() => {
  // we add the Three.js setup here
   
  }, []);

  return (
    <div ref={containerRef} className={styles.container}>
      <canvas ref={canvasRef} className={styles.canvas} />
    </div>
  );
}
components/RippleEffect/style.module.scss
.container {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
  background-color: #000;
  background-image: url('Your Image URL');
  background-size: cover;
  background-repeat: no-repeat;
  background-position: center center;
}

.canvas {
  display: block;
  width: 100%;
  height: 100%;
}

Shader Code

The core of the effect lives in the shaders. We'll create `shaders.js` and export the GLSL code as template strings. The simulation shader handles the wave propagation, while the render shader combines the wave texture with the text texture to produce the final distorted output.

components/RippleEffect/shaders.js
// Simulation Vertex Shader
export const simulationVertexShader = `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

// Simulation Fragment Shader
export const simulationFragmentShader = `
  precision highp float;
  varying vec2 vUv;
  uniform sampler2D textureA;
  uniform vec2 mouse;
  uniform vec2 resolution;
  uniform float time;
  uniform float frame;
  uniform float speed;
  uniform float damping;
  uniform float rippleStrength;

  void main() {
    vec2 uv = vUv;
    float stepX = 1.0 / resolution.x;
    float stepY = 1.0 / resolution.y;

    // Sample neighboring pixels (simple 4-neighbor average)
    float c = texture2D(textureA, uv).r;
    float t = texture2D(textureA, uv + vec2(0.0, stepY)).r;
    float b = texture2D(textureA, uv - vec2(0.0, stepY)).r;
    float l = texture2D(textureA, uv - vec2(stepX, 0.0)).r;
    float r = texture2D(textureA, uv + vec2(stepX, 0.0)).r;

    // Wave equation: new value = average of neighbors minus current (with damping)
    float laplacian = (t + b + l + r) * 0.25 - c;
    float newValue = c + laplacian * speed;

    // Damping
    newValue *= damping;

    // Mouse disturbance
    vec2 mouseUV = mouse / resolution;
    float dist = distance(uv, mouseUV);
    float ripple = exp(-dist * 500.0) * rippleStrength;
    newValue += ripple;

    gl_FragColor = vec4(newValue, newValue, newValue, 1.0);
  }
`;

// Render Vertex Shader
export const renderVertexShader = `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

// Render Fragment Shader
export const renderFragmentShader = `
  precision highp float;
  varying vec2 vUv;
  uniform sampler2D textureA; // wave texture
  uniform sampler2D textureB; // text texture

  void main() {
    // Get wave value (distortion amount)
    float wave = texture2D(textureA, vUv).r;

    // Offset UV based on wave (simple refraction)
    vec2 offset = vec2(wave * 0.02, wave * 0.02);
    vec2 distortedUv = vUv + offset;

    // Sample text texture with distorted UV
    vec4 textColor = texture2D(textureB, distortedUv);

    gl_FragColor = textColor;
  }
`;

Three.js Setup Inside useEffect

Now we'll fill the `useEffect` with the full Three.js logic. This includes creating the renderer, render targets, materials, and the animation loop. The ping-pong technique is used to update the wave simulation each frame.

components/RippleEffect/index.jsx (continued)

  useEffect(() => {
     const canvas = canvasRef.current;

    // Scene, camera, renderer
    const scene = new THREE.Scene();
    const simScene = new THREE.Scene();
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

    const renderer = new THREE.WebGLRenderer({
      canvas,
      antialias: true,
      alpha: true,
      preserveDrawingBuffer: true,
    });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.setSize(window.innerWidth, window.innerHeight);

    const mouse = mouseRef.current;

    // Render targets for ping-pong simulation
    const width = window.innerWidth * window.devicePixelRatio;
    const height = window.innerHeight * window.devicePixelRatio;
    const options = {
      format: THREE.RGBAFormat,
      type: THREE.FloatType,
      minFilter: THREE.LinearFilter,
      magFilter: THREE.LinearFilter,
      stencilBuffer: false,
      depthBuffer: false,
    };

    let rtA = new THREE.WebGLRenderTarget(width, height, options);
    let rtB = new THREE.WebGLRenderTarget(width, height, options);

    // Simulation material
    const simMaterial = new THREE.ShaderMaterial({
      uniforms: {
        textureA: { value: null },
        mouse: { value: mouse },
        resolution: { value: new THREE.Vector2(width, height) },
        time: { value: 0 },
        frame: { value: 0 },
      },
      vertexShader: simulationVertexShader,
      fragmentShader: simulationFragmentShader,
    });

    // Render material
    const renderMaterial = new THREE.ShaderMaterial({
      uniforms: {
        textureA: { value: null },
        textureB: { value: null },
      },
      vertexShader: renderVertexShader,
      fragmentShader: renderFragmentShader,
      transparent: true,
    });

    const plane = new THREE.PlaneGeometry(2, 2);
    const simQuad = new THREE.Mesh(plane, simMaterial);
    const renderQuad = new THREE.Mesh(plane, renderMaterial);

    simScene.add(simQuad);
    scene.add(renderQuad);

    // Create canvas for text texture
    const textCanvas = document.createElement('canvas');
    let canvasWidth = window.innerWidth * window.devicePixelRatio;
    let canvasHeight = window.innerHeight * window.devicePixelRatio;
    textCanvas.width = canvasWidth;
    textCanvas.height = canvasHeight;
    const ctx = textCanvas.getContext('2d', { alpha: true });

    let fontSize = Math.round(250 * window.devicePixelRatio);
    const textString = "Please Move the cursor over the text";

    function clearCanvas() {
      ctx.clearRect(0, 0, textCanvas.width, textCanvas.height);
      ctx.fillStyle = "transparent";
      ctx.fillRect(0, 0, textCanvas.width, textCanvas.height);
    }

    function drawTwoLineText() {
      clearCanvas();
      fontSize = Math.round(250 * window.devicePixelRatio);
      const maxWidth = textCanvas.width * 0.9;

      ctx.fillStyle = '#fef4b8';
      ctx.font = `bold ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';

      const words = textString.split(' ');
      let splitIndex = -1;
      for (let offset = 0; offset <= Math.floor(words.length / 2); offset++) {
        const mid = Math.floor(words.length / 2);
        const iLeft = mid - offset;
        const iRight = mid + offset;
        const candidates = [];
        if (iLeft > 0) candidates.push(iLeft);
        if (iRight < words.length) candidates.push(iRight);
        let found = false;
        for (const idx of candidates) {
          const line1 = words.slice(0, idx).join(' ');
          const line2 = words.slice(idx).join(' ');
          if (
            ctx.measureText(line1).width <= maxWidth &&
            ctx.measureText(line2).width <= maxWidth
          ) {
            splitIndex = idx;
            found = true;
            break;
          }
        }
        if (found) break;
      }

      if (splitIndex === -1) {
        let tempFont = fontSize;
        while (tempFont > 10) {
          ctx.font = `bold ${tempFont}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`;
          const idx = Math.floor(words.length / 2);
          const line1 = words.slice(0, idx).join(' ');
          const line2 = words.slice(idx).join(' ');
          if (
            ctx.measureText(line1).width <= maxWidth &&
            ctx.measureText(line2).width <= maxWidth
          ) {
            fontSize = tempFont;
            splitIndex = idx;
            break;
          }
          tempFont = Math.floor(tempFont * 0.95);
        }
        if (splitIndex === -1) splitIndex = Math.floor(words.length / 2);
        ctx.font = `bold ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`;
      }

      const line1 = words.slice(0, splitIndex).join(' ');
      const line2 = words.slice(splitIndex).join(' ');
      const lineHeight = fontSize * 0.6;

      ctx.fillText(line1, textCanvas.width / 2, textCanvas.height / 2 - lineHeight / 2);
      ctx.fillText(line2, textCanvas.width / 2, textCanvas.height / 2 + lineHeight / 2);
    }

    drawTwoLineText();

    const textTexture = new THREE.CanvasTexture(textCanvas);
    textTexture.minFilter = THREE.LinearFilter;
    textTexture.magFilter = THREE.LinearFilter;
    textTexture.format = THREE.RGBAFormat;

    // Mouse move listener
    const onMouseMove = (e) => {
      mouse.x = e.clientX * window.devicePixelRatio;
      mouse.y = (window.innerHeight - e.clientY) * window.devicePixelRatio;
    };
    const onMouseLeave = () => {
      mouse.set(0, 0);
    };

    renderer.domElement.addEventListener('mousemove', onMouseMove);
    renderer.domElement.addEventListener('mouseleave', onMouseLeave);

    // Resize handler
    const onResize = () => {
      const newWidth = window.innerWidth * window.devicePixelRatio;
      const newHeight = window.innerHeight * window.devicePixelRatio;

      renderer.setSize(window.innerWidth, window.innerHeight);
      rtA.setSize(newWidth, newHeight);
      rtB.setSize(newWidth, newHeight);
      simMaterial.uniforms.resolution.value.set(newWidth, newHeight);

      textCanvas.width = newWidth;
      textCanvas.height = newHeight;
      drawTwoLineText();
      textTexture.needsUpdate = true;
    };
    window.addEventListener('resize', onResize);

    // Animation loop
    let frame = 0;
    let animationFrameId;

    const animate = () => {
      simMaterial.uniforms.frame.value = frame++;
      simMaterial.uniforms.time.value = performance.now() / 1000;

      simMaterial.uniforms.textureA.value = rtA.texture;
      renderer.setRenderTarget(rtB);
      renderer.render(simScene, camera);

      renderMaterial.uniforms.textureA.value = rtB.texture;
      renderMaterial.uniforms.textureB.value = textTexture;
      renderer.setRenderTarget(null);
      renderer.render(scene, camera);

      const temp = rtA;
      rtA = rtB;
      rtB = temp;

      animationFrameId = requestAnimationFrame(animate);
    };

    animate();

    // Cleanup on unmount
    return () => {
      renderer.domElement.removeEventListener('mousemove', onMouseMove);
      renderer.domElement.removeEventListener('mouseleave', onMouseLeave);
      window.removeEventListener('resize', onResize);
      cancelAnimationFrame(animationFrameId);

      renderer.dispose();
      rtA.dispose();
      rtB.dispose();
      textTexture.dispose();
    };
   
  }, []);

Global Styles

Update your global CSS to set the background and text color. This matches the original design.

app/globals.css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body {
  width: 100%;
  height: 100%;
}

body {
  margin: 0;
  min-height: 100vh;
  font-family: "Test Söhne", sans-serif;
  background-image: url('/image.png');
  background-size: cover;
  background-repeat: no-repeat;
  background-position: center center;
  color: #fef4b8;
  overflow-x: hidden;
  padding-top: 80px;
}

Using the Component

Now you can import and use the `RippleEffect` component in any page. For example, in `app/page.js`, you can add your content on top of the canvas.

app/page.js
import RippleEffect from '@/components/RippleEffect';

export default function Home() {
  return (
    <main>
      <RippleEffect />
      <h1 style={{ position: 'relative', zIndex: 2, color: '#fef4b8' }}>
        Your content goes here
      </h1>
    </main>
  );
}

Customizing the Animation

You can tweak several uniforms to change the behavior of the ripple effect. Here are the key variables and their effects:

`speed` – Controls how fast waves propagate (default: 0.5).

`damping` – Determines how quickly waves fade out (default: 0.98). Lower values make waves disappear faster.

`rippleStrength` – Intensity of the ripple created by the mouse (default: 0.5).

`offsetMultiplier` – In the render shader, you can adjust `wave * 0.02` to change distortion strength.

To make these adjustable via props, modify the component to accept them and pass them to the shader uniforms.

Wrapping Up

You now have a smooth water ripple hover effect built with Three.js and Next.js. Customize the text, colors, and shaders to match your brand and add an interactive feel to your site.

Special thanks to the original inspiration and the CodeGrid . Happy coding!