Skip to content

Liquid Ripple

Pointer movement and clicks ripple across a surface.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/liquid-ripple.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/webgl/noise.glsl.ts
    /**
     * Shared GLSL ES 3.00 snippets. Concatenate the ones a shader needs before its main():
     *
     *   const fragment = `${GLSL_HASH}${GLSL_SIMPLEX}${GLSL_FBM} void main() { … }`;
     *
     * GLSL_SIMPLEX needs GLSL_HASH, and GLSL_FBM needs GLSL_SIMPLEX.
     */
    
    /** Sine-free hashes: stable across GPUs, no visible banding. */
    export const GLSL_HASH = /* glsl */ `
    float hash12(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * 0.1031);
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.x + p3.y) * p3.z);
    }
    
    vec2 hash22(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.xx + p3.yz) * p3.zy);
    }
    `;
    
    /** 2D simplex noise in roughly [-1, 1]. */
    export const GLSL_SIMPLEX = /* glsl */ `
    vec2 simplexGradient(vec2 cell) {
      float angle = hash12(cell) * 6.28318530718;
      return vec2(cos(angle), sin(angle));
    }
    
    float snoise(vec2 p) {
      const float F2 = 0.36602540378; // (sqrt(3) - 1) / 2
      const float G2 = 0.21132486540; // (3 - sqrt(3)) / 6
      vec2 cell = floor(p + (p.x + p.y) * F2);
      vec2 a = p - cell + (cell.x + cell.y) * G2;
      vec2 step1 = a.x > a.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
      vec2 b = a - step1 + G2;
      vec2 c = a - 1.0 + 2.0 * G2;
      vec3 falloff = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0);
      falloff *= falloff;
      falloff *= falloff;
      vec3 contribution = vec3(
        dot(a, simplexGradient(cell)),
        dot(b, simplexGradient(cell + step1)),
        dot(c, simplexGradient(cell + 1.0))
      );
      return 70.0 * dot(falloff, contribution);
    }
    `;
    
    /** Fractal Brownian motion over simplex noise; octaves is clamped to 1–8. Roughly [-1, 1]. */
    export const GLSL_FBM = /* glsl */ `
    float fbm(vec2 p, int octaves) {
      const mat2 rotate = mat2(0.8, 0.6, -0.6, 0.8);
      float sum = 0.0;
      float amplitude = 0.5;
      for (int i = 0; i < 8; i++) {
        if (i >= octaves) break;
        sum += amplitude * snoise(p);
        p = rotate * p * 2.03 + 17.1;
        amplitude *= 0.5;
      }
      return sum;
    }
    `;
    
    /** Ordered (Bayer) dither thresholds in [0, 1) for 2×2, 4×4 and 8×8 matrices. Pass pixel coordinates. */
    export const GLSL_BAYER = /* glsl */ `
    float bayer2(vec2 a) {
      a = floor(a);
      return fract(a.x * 0.5 + a.y * a.y * 0.75);
    }
    
    float bayer4(vec2 a) {
      return bayer2(0.5 * a) * 0.25 + bayer2(a);
    }
    
    float bayer8(vec2 a) {
      return bayer4(0.5 * a) * 0.25 + bayer2(a);
    }
    `;
    
    /** Hash, simplex and fBm together, in dependency order. */
    export const GLSL_NOISE = GLSL_HASH + GLSL_SIMPLEX + GLSL_FBM;
    lib/webgl/use-shader-canvas.ts
    import { useEffect, useRef, useState } from "react";
    
    /** Numbers, vectors (arrays) and, for sampler2D uniforms, an image, canvas or video element. */
    export type UniformValue = number | boolean | readonly number[] | Float32Array | TexImageSource;
    
    const isTextureSource = (value: UniformValue): value is TexImageSource =>
      typeof value === "object" && value !== null && !Array.isArray(value) && !ArrayBuffer.isView(value);
    
    /**
     * - `idle`: not started yet (always the value on the server and the first client render)
     * - `running`: animating
     * - `static`: a single frame is on screen (reduced motion, paused, offscreen or tab hidden)
     * - `fallback`: WebGL2 is unavailable, the shader failed to compile or the context was lost
     */
    export type ShaderStatus = "idle" | "running" | "static" | "fallback";
    
    export interface UseShaderCanvasOptions {
      /**
       * Fragment shader source. Unless it starts with `#version`, the hook prepends a header with
       * `precision highp float`, the built-in uniforms `u_time` (seconds), `u_resolution` (pixels),
       * `u_mouse` (0–1, y up) and `u_pointer` (0–1, eases to 1 while the pointer is inside),
       * `in vec2 v_uv` and `out vec4 fragColor`. Output premultiplied alpha.
       */
      fragmentShader: string;
      /**
       * Custom uniforms by name. Read every frame, so updating them never restarts the shader. Pass a
       * function to compute them per frame from refs or other values that change without re-rendering.
       */
      uniforms?: Record<string, UniformValue> | (() => Record<string, UniformValue>);
      /** Clock multiplier. @default 1 */
      speed?: number;
      /** Freezes the clock and keeps the current frame on screen. @default false */
      paused?: boolean;
      /** Fraction of the display resolution to render at, clamped to 0.5–1. @default 1 */
      renderScale?: number;
      /** Highest device pixel ratio used. @default 1.5 */
      maxDpr?: number;
      /** Track the pointer over the container into `u_mouse` and `u_pointer`. @default false */
      pointer?: boolean;
      /** Clock value, in seconds, of the single frame drawn under reduced motion. @default 6 */
      staticTime?: number;
      /**
       * Render even on software WebGL (SwiftShader, llvmpipe), where compiling and every frame run on
       * the CPU and block the main thread. By default those renderers get the fallback. @default false
       */
      allowSoftware?: boolean;
    }
    
    const VERTEX_SHADER = /* glsl */ `#version 300 es
    out vec2 v_uv;
    void main() {
      vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
      v_uv = p;
      gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
    }`;
    
    const FRAGMENT_HEADER = /* glsl */ `#version 300 es
    precision highp float;
    uniform float u_time;
    uniform vec2 u_resolution;
    uniform vec2 u_mouse;
    uniform float u_pointer;
    in vec2 v_uv;
    out vec4 fragColor;
    `;
    
    interface UniformSlot {
      location: WebGLUniformLocation;
      type: number;
    }
    
    const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
    
    function compile(gl: WebGL2RenderingContext, type: number, source: string) {
      const shader = gl.createShader(type);
      if (!shader) return null;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
      if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: shader failed to compile\n${gl.getShaderInfoLog(shader)}`);
      gl.deleteShader(shader);
      return null;
    }
    
    function link(gl: WebGL2RenderingContext, fragmentSource: string) {
      const source = fragmentSource.trimStart().startsWith("#version") ? fragmentSource.trimStart() : FRAGMENT_HEADER + fragmentSource;
      const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
      const fragment = compile(gl, gl.FRAGMENT_SHADER, source);
      const program = vertex && fragment ? gl.createProgram() : null;
      if (program && vertex && fragment) {
        gl.attachShader(program, vertex);
        gl.attachShader(program, fragment);
        gl.linkProgram(program);
        gl.detachShader(program, vertex);
        gl.detachShader(program, fragment);
      }
      if (vertex) gl.deleteShader(vertex);
      if (fragment) gl.deleteShader(fragment);
      if (!program) return null;
      if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
      if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: program failed to link\n${gl.getProgramInfoLog(program)}`);
      gl.deleteProgram(program);
      return null;
    }
    
    function collectUniforms(gl: WebGL2RenderingContext, program: WebGLProgram) {
      const slots = new Map<string, UniformSlot>();
      const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number;
      for (let i = 0; i < count; i++) {
        const info = gl.getActiveUniform(program, i);
        const location = info && gl.getUniformLocation(program, info.name);
        if (info && location) slots.set(info.name.replace(/\[0\]$/, ""), { location, type: info.type });
      }
      return slots;
    }
    
    function setUniform(gl: WebGL2RenderingContext, { location, type }: UniformSlot, value: Exclude<UniformValue, TexImageSource>) {
      const isInt = type === gl.INT || type === gl.BOOL;
      if (typeof value === "boolean" || typeof value === "number") {
        const n = Number(value);
        if (isInt) gl.uniform1i(location, n);
        else gl.uniform1f(location, n);
        return;
      }
      const list = value as number[] | Float32Array;
      if (isInt) gl.uniform1iv(location, Int32Array.from(list));
      else if (type === gl.FLOAT_VEC2) gl.uniform2fv(location, list);
      else if (type === gl.FLOAT_VEC3) gl.uniform3fv(location, list);
      else if (type === gl.FLOAT_VEC4) gl.uniform4fv(location, list);
      else gl.uniform1fv(location, list);
    }
    
    /**
     * Renders a fragment shader with WebGL2 on a canvas the hook creates inside the element you
     * attach `ref` to (give that element a size and `position: relative`, `absolute` or `fixed`).
     *
     * One fullscreen triangle, no three.js. The canvas follows the element's size, caps the pixel
     * ratio, pauses offscreen and in hidden tabs, draws a single static frame under reduced motion
     * (software WebGL such as SwiftShader gets the fallback unless allowSoftware is set),
     * recovers from context loss and releases the GPU context on unmount. Uniform values can be
     * numbers, arrays (vec2–vec4 or float arrays) or, for sampler2D uniforms, an image, canvas or
     * video element (uploaded once it has pixels; pass a new element or re-render to refresh). When `status` is
     * `fallback` (or before the first frame), whatever you render behind the canvas shows through.
     */
    export function useShaderCanvas<T extends HTMLElement = HTMLDivElement>(options: UseShaderCanvasOptions) {
      const ref = useRef<T>(null);
      const [status, setStatus] = useState<ShaderStatus>("idle");
      const latest = useRef(options);
      const wake = useRef<() => void>(() => {});
      const time = useRef(0);
    
      // Keep the render loop reading fresh props, and let it react to paused/uniform changes.
      useEffect(() => {
        latest.current = options;
        wake.current();
      });
    
      const { fragmentShader } = options;
    
      useEffect(() => {
        const host = ref.current;
        if (!host) return;
    
        const canvas = document.createElement("canvas");
        canvas.setAttribute("aria-hidden", "true");
        Object.assign(canvas.style, {
          position: "absolute",
          inset: "0",
          width: "100%",
          height: "100%",
          display: "block",
          pointerEvents: "none",
          opacity: "0",
          transition: "opacity 500ms ease-out",
        });
        host.appendChild(canvas);
    
        const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
        const mouse = [0.5, 0.5];
        const mouseTarget = [0.5, 0.5];
        let gl: WebGL2RenderingContext | null = null;
        let program: WebGLProgram | null = null;
        let uniforms = new Map<string, UniformSlot>();
        const textures = new Map<string, { texture: WebGLTexture; unit: number; source: TexImageSource | null; ready: boolean }>();
        let reported: ShaderStatus = "idle";
        let raf = 0;
        let lastFrame = 0;
        let clock = 0;
        let pointer = 0;
        let pointerTarget = 0;
        let inView = false;
        let pageVisible = document.visibilityState === "visible";
        let needsResize = true;
        let drawn = false;
        let lost = false;
        let software = false;
    
        const report = (next: ShaderStatus) => {
          if (next === reported) return;
          reported = next;
          setStatus(next);
        };
    
        const build = () => {
          gl ??= canvas.getContext("webgl2", {
            alpha: true,
            antialias: false,
            depth: false,
            stencil: false,
            premultipliedAlpha: true,
            powerPreference: "low-power",
          });
          if (gl) {
            const info = gl.getExtension("WEBGL_debug_renderer_info");
            const renderer = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
            software = /swiftshader|llvmpipe|softpipe|software|basic render/i.test(renderer);
          }
          // On software WebGL even compiling blocks the main thread, so use the CSS fallback instead.
          program = gl && !(software && !latest.current.allowSoftware) ? link(gl, fragmentShader) : null;
          uniforms = gl && program ? collectUniforms(gl, program) : new Map();
          textures.clear();
          needsResize = true;
          return program !== null;
        };
    
        const resize = () => {
          if (!gl) return;
          const { renderScale = 1, maxDpr = 1.5 } = latest.current;
          const scale = Math.min(window.devicePixelRatio || 1, maxDpr) * clamp(renderScale, 0.5, 1);
          const width = Math.max(1, Math.round(host.clientWidth * scale));
          const height = Math.max(1, Math.round(host.clientHeight * scale));
          if (canvas.width !== width || canvas.height !== height) {
            canvas.width = width;
            canvas.height = height;
          }
          gl.viewport(0, 0, width, height);
          needsResize = false;
        };
    
        const draw = (time: number) => {
          if (!gl || !program || lost) return;
          if (needsResize) resize();
          gl.useProgram(program);
          const set = (name: string, value: UniformValue) => {
            const slot = uniforms.get(name);
            if (!slot || !gl) return;
            if (isTextureSource(value)) bindTexture(name, slot, value);
            else setUniform(gl, slot, value);
          };
          set("u_time", time);
          set("u_resolution", [canvas.width, canvas.height]);
          set("u_mouse", mouse);
          set("u_pointer", pointer);
          const source = latest.current.uniforms;
          const custom = typeof source === "function" ? source() : source;
          if (custom) for (const name in custom) set(name, custom[name]);
          gl.drawArrays(gl.TRIANGLES, 0, 3);
          if (!drawn) {
            drawn = true;
            canvas.style.opacity = "1";
          }
        };
    
        // Textures upload once per source (and every frame for video), as soon as the source has pixels.
        const bindTexture = (name: string, slot: UniformSlot, source: TexImageSource) => {
          if (!gl || slot.type !== gl.SAMPLER_2D) return;
          let entry = textures.get(name);
          if (!entry) {
            const texture = gl.createTexture();
            if (!texture) return;
            entry = { texture, unit: textures.size, source: null, ready: false };
            textures.set(name, entry);
          }
          gl.activeTexture(gl.TEXTURE0 + entry.unit);
          gl.bindTexture(gl.TEXTURE_2D, entry.texture);
          const hasPixels =
            typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement
              ? source.complete && source.naturalWidth > 0
              : typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement
                ? source.readyState >= 2
                : true;
          const isVideo = typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement;
          if (hasPixels && (entry.source !== source || !entry.ready || isVideo)) {
            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
            gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
            entry.source = source;
            entry.ready = true;
          }
          gl.uniform1i(slot.location, entry.unit);
        };
    
        const still = () => reducedMotion.matches;
        const shouldRun = () => !lost && program !== null && inView && pageVisible && !latest.current.paused && !still();
    
        const frame = (now: number) => {
          raf = 0;
          if (!shouldRun()) return settle();
          const dt = lastFrame ? Math.min((now - lastFrame) / 1000, 0.1) : 0;
          lastFrame = now;
          clock += dt * (latest.current.speed ?? 1);
          const ease = 1 - Math.exp(-dt * 6);
          mouse[0] += (mouseTarget[0] - mouse[0]) * ease;
          mouse[1] += (mouseTarget[1] - mouse[1]) * ease;
          pointer += (pointerTarget - pointer) * ease;
          draw(clock);
          time.current = clock;
          report("running");
          raf = requestAnimationFrame(frame);
        };
    
        // Not animating: keep one up-to-date frame on screen.
        const settle = () => {
          lastFrame = 0;
          if (lost || !program) return;
          time.current = still() ? (latest.current.staticTime ?? 6) : clock;
          draw(time.current);
          report("static");
        };
    
        const update = () => {
          if (shouldRun()) {
            if (!raf) raf = requestAnimationFrame(frame);
            return;
          }
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          settle();
        };
    
        const intersection = new IntersectionObserver(
          ([entry]) => {
            inView = entry.isIntersecting;
            update();
          },
          { rootMargin: "120px" },
        );
        const resizeObserver = new ResizeObserver(() => {
          needsResize = true;
          if (!raf) settle();
        });
        const onVisibility = () => {
          pageVisible = document.visibilityState === "visible";
          update();
        };
        const onPointerMove = (event: PointerEvent) => {
          const rect = host.getBoundingClientRect();
          const x = (event.clientX - rect.left) / rect.width;
          const y = 1 - (event.clientY - rect.top) / rect.height;
          const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1;
          if (inside) {
            mouseTarget[0] = x;
            mouseTarget[1] = y;
          }
          pointerTarget = inside ? 1 : 0;
        };
        const onPointerLeave = () => {
          pointerTarget = 0;
        };
        const onContextLost = (event: Event) => {
          event.preventDefault();
          lost = true;
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          report("fallback");
        };
        const onContextRestored = () => {
          lost = false;
          if (build()) update();
          else report("fallback");
        };
        const trackPointer = latest.current.pointer === true;
    
        // Start on the next frame: keeps setup out of the effect body and off the hydration task.
        const start = requestAnimationFrame(() => {
          if (!build()) return report("fallback");
          canvas.addEventListener("webglcontextlost", onContextLost);
          canvas.addEventListener("webglcontextrestored", onContextRestored);
          document.addEventListener("visibilitychange", onVisibility);
          reducedMotion.addEventListener("change", update);
          if (trackPointer) {
            window.addEventListener("pointermove", onPointerMove, { passive: true });
            document.documentElement.addEventListener("pointerleave", onPointerLeave);
          }
          intersection.observe(host);
          resizeObserver.observe(host);
          wake.current = update;
        });
    
        return () => {
          cancelAnimationFrame(start);
          if (raf) cancelAnimationFrame(raf);
          wake.current = () => {};
          intersection.disconnect();
          resizeObserver.disconnect();
          canvas.removeEventListener("webglcontextlost", onContextLost);
          canvas.removeEventListener("webglcontextrestored", onContextRestored);
          document.removeEventListener("visibilitychange", onVisibility);
          reducedMotion.removeEventListener("change", update);
          window.removeEventListener("pointermove", onPointerMove);
          document.documentElement.removeEventListener("pointerleave", onPointerLeave);
          if (gl) {
            textures.forEach((entry) => gl?.deleteTexture(entry.texture));
            if (program) gl.deleteProgram(program);
            gl.getExtension("WEBGL_lose_context")?.loseContext();
          }
          canvas.remove();
        };
      }, [fragmentShader]);
    
      /** `time` holds the shader clock (seconds, as passed to u_time), e.g. to timestamp clicks. */
      return { ref, status, time };
    }
  3. Copy the source

    components/ui/liquid-ripple/liquid-ripple.tsx
    "use client";
    
    import { useEffect, useRef, type ComponentProps, type PointerEvent, type ReactNode } from "react";
    import { cn } from "@/lib/utils";
    import { hexToVec3 } from "@/lib/color";
    import { GLSL_NOISE } from "@/lib/webgl/noise.glsl";
    import { useShaderCanvas } from "@/lib/webgl/use-shader-canvas";
    
    export interface LiquidRippleProps extends Omit<ComponentProps<"div">, "children"> {
      /** Content rendered above the surface. */
      children?: ReactNode;
      /** Water tint. @default "#BFDBFE" */
      color?: string;
      /** Surface base color. @default "#F8FAFF" */
      backgroundColor?: string;
      /** Ripple strength multiplier. @default 1 */
      strength?: number;
      /** Pointer movement and clicks create ripples. @default true */
      interactive?: boolean;
      /** Occasional ripples on their own, so the surface feels alive. @default true */
      autoRipple?: boolean;
      /** Animation speed multiplier. @default 1 */
      speed?: number;
      /** Freeze on the current frame. @default false */
      paused?: boolean;
    }
    
    const MAX_RIPPLES = 8;
    
    const FRAGMENT = /* glsl */ `${GLSL_NOISE}
    uniform vec4 u_ripples[8]; uniform vec3 u_tint; uniform vec3 u_bg;
    void main() {
      vec2 uv = gl_FragCoord.xy / u_resolution;
      float aspect = u_resolution.x / u_resolution.y;
      float h = 0.0; vec2 grad = vec2(0.0);
      for (int i = 0; i < 8; i++) {
        vec4 r = u_ripples[i];
        float age = u_time - r.z;
        if (r.w <= 0.0 || age < 0.0 || age > 5.0) continue;
        vec2 d = (uv - r.xy) * vec2(aspect, 1.0);
        float dist = length(d);
        float front = age * 0.32;
        float ring = exp(-pow((dist - front) * 9.0, 2.0));
        float wave = sin((dist - front) * 46.0) * ring * exp(-age * 0.9) * r.w;
        h += wave;
        grad += (d / max(dist, 1e-4)) * wave;
      }
      vec2 suv = uv + grad * 0.012;
      float caustic = fbm(suv * vec2(aspect, 1.0) * 3.2 + u_time * 0.04, 4) * 0.5 + 0.5;
      vec3 col = mix(u_bg, u_tint, 0.18 + 0.42 * smoothstep(0.35, 0.85, caustic));
      col += clamp(h, 0.0, 1.0) * 0.28;
      col -= clamp(-h, 0.0, 1.0) * 0.12 * (1.0 - u_tint);
      fragColor = vec4(col, 1.0);
    }`;
    
    // Deterministic positions for automatic ripples.
    const AUTO = [
      [0.28, 0.62],
      [0.72, 0.38],
      [0.5, 0.72],
      [0.18, 0.3],
      [0.82, 0.7],
      [0.42, 0.42],
    ];
    
    export function LiquidRipple({
      children,
      color = "#BFDBFE",
      backgroundColor = "#F8FAFF",
      strength = 1,
      interactive = true,
      autoRipple = true,
      speed = 1,
      paused = false,
      className,
      style,
      ...props
    }: LiquidRippleProps) {
      // Ripples live in a ring buffer the shader reads every frame: x, y, start time, strength.
      const ripples = useRef(new Float32Array(MAX_RIPPLES * 4));
      const next = useRef(0);
      const lastMove = useRef({ t: 0, x: -1, y: -1 });
      const tint = hexToVec3(color);
      const background = hexToVec3(backgroundColor);
      const { ref, time } = useShaderCanvas<HTMLDivElement>({
        fragmentShader: FRAGMENT,
        renderScale: 0.7,
        speed,
        paused,
        uniforms: () => ({ u_ripples: ripples.current, u_tint: tint, u_bg: background }),
      });
    
      const add = (x: number, y: number, power: number) => {
        const slot = (next.current % MAX_RIPPLES) * 4;
        ripples.current.set([x, y, time.current, power * strength], slot);
        next.current += 1;
      };
    
      useEffect(() => {
        if (!autoRipple || paused || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
        let i = 0;
        const timer = window.setInterval(() => {
          const [x, y] = AUTO[i++ % AUTO.length];
          add(x, y, 0.7);
        }, 2600);
        return () => window.clearInterval(timer);
        // eslint-disable-next-line react-hooks/exhaustive-deps -- add only reads refs
      }, [autoRipple, paused]);
    
      const point = (event: PointerEvent<HTMLDivElement>) => {
        const rect = event.currentTarget.getBoundingClientRect();
        return [(event.clientX - rect.left) / rect.width, 1 - (event.clientY - rect.top) / rect.height] as const;
      };
    
      return (
        <div
          className={cn("relative isolate overflow-hidden", className)}
          style={{ background: `radial-gradient(120% 90% at 50% 40%, color-mix(in oklab, ${color} 45%, ${backgroundColor}), ${backgroundColor})`, ...style }}
          onPointerMove={(event) => {
            if (!interactive) return;
            const [x, y] = point(event);
            const now = performance.now();
            const moved = Math.hypot(x - lastMove.current.x, y - lastMove.current.y);
            if (now - lastMove.current.t < 140 || moved < 0.03) return;
            lastMove.current = { t: now, x, y };
            add(x, y, 0.35);
          }}
          onPointerDown={(event) => {
            if (!interactive) return;
            const [x, y] = point(event);
            add(x, y, 1);
          }}
          {...props}
        >
          <div ref={ref} aria-hidden="true" className="absolute inset-0 -z-10" />
          {children}
        </div>
      );
    }

Usage

import { LiquidRipple } from "@/components/ui/liquid-ripple/liquid-ripple";

<LiquidRipple className="h-80">
  <YourContent />
</LiquidRipple>

API reference

PropTypeDefaultDescription
childrenReactNode–Content rendered above the surface.
colorstring"#BFDBFE"Water tint.
backgroundColorstring"#F8FAFF"Surface base color.
strengthnumber1Ripple strength multiplier.
interactivebooleantruePointer movement and clicks create ripples.
autoRipplebooleantrueOccasional ripples on their own, so the surface feels alive.
speednumber1Animation speed multiplier.
pausedbooleanfalseFreeze on the current frame.

Also accepts every native <div> attribute, including ref and className.

  • Up to eight ripples live in a ring buffer the shader reads every frame (position, start time, strength). Pointer moves add small ripples at most every 140ms, clicks add a strong one, and each wave front travels outward while it decays.
  • Built on useShaderCanvas: it pauses offscreen and in hidden tabs, caps the pixel ratio at 1.5, recovers from context loss and releases the GPU on unmount.

Accessibility

  • Ripples are decorative. Children stay fully interactive, and clicking anywhere also creates a ripple.
  • With reduced motion the shader draws a single still frame. Without WebGL2, or on software rendering with no GPU, a CSS fallback in the same colors is shown.