Skip to content
goodtabs UI
BrowseBrowse components

Ripple image

An image that takes a soft refractive ripple from the pointer, then settles.

npx shadcn@latest add https://ui.goodtabs.co/r/ripple-image.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/ripple-image

A full window image with a soft idle drop every 1.6 seconds. Move the pointer across it.

In an article

A captioned figure at 12px depth, which stays still until the pointer moves.

Usage

import { RippleImage } from "@/components/ui/ripple-image"

<RippleImage src="/photo.jpg" alt="What the photo shows" className="rounded-lg" />

Props

PropTypeDefaultDescription
srcstringThe image. Same origin, or served with CORS headers.
altstringAlt text for the real image, which stays in the DOM.
depthnumber16How far a ripple bends the image at its peak, in CSS pixels.
idlenumber0Seconds between soft drops while no pointer is over the image. 0 turns them off.

Source

ripple-image/ripple-image.tsx
"use client";

import * as React from "react";

import { ShaderCanvas } from "@/components/ui/shader-canvas";
import { cn } from "@/lib/utils";

const FRAGMENT = `precision mediump float;
uniform vec2 uResolution;
uniform float uScale;
uniform float uLoaded;
uniform float uDepth;
uniform sampler2D uImage;
uniform vec2 uImageSize;
uniform vec4 uRipple[8];

// The same crop as object-cover, centred.
vec2 cover(vec2 uv, vec2 box) {
  float excess = (box.x / box.y) / (uImageSize.x / uImageSize.y);
  vec2 ratio = excess > 1.0 ? vec2(1.0, 1.0 / excess) : vec2(excess, 1.0);
  return uv * ratio + (1.0 - ratio) * 0.5;
}

void main() {
  gl_FragColor = vec4(0.0);
  if (uLoaded < 0.5) return;

  vec2 box = uResolution / uScale;
  vec2 p = gl_FragCoord.xy / uScale;
  vec2 bend = vec2(0.0);
  for (int i = 0; i < 8; i++) {
    vec4 ripple = uRipple[i];
    if (ripple.w <= 0.0) continue;
    vec2 d = p - ripple.xy;
    float dist = max(length(d), 0.001);
    float age = ripple.z;
    // A ring travels out at 240px a second and widens as it goes, the way water spreads energy thin.
    float x = (dist - age * 240.0) / (44.0 + age * 40.0);
    float strength = ripple.w * exp(-x * x) * exp(-age * 2.4);
    strength *= smoothstep(0.0, 0.1, age) * smoothstep(0.0, 24.0, dist);
    bend += d / dist * sin(x * 3.2) * strength;
  }

  // Where nothing is bent the canvas stays clear, and the real image shows at full sharpness.
  float amount = length(bend);
  if (amount < 0.002) return;
  vec3 color = texture2D(uImage, cover((p + bend * uDepth) / box, box)).rgb;
  // One light from above: the side of a ring that faces it brightens, the other side falls away.
  color *= 1.0 + bend.y * 0.25;
  gl_FragColor = vec4(color, 1.0) * smoothstep(0.002, 0.02, amount);
}`;

const COUNT = 8;
const LIFE = 2.2;
/** A new ripple needs this much travel, in CSS pixels. */
const SPACING = 48;
/** And this long since the last one, in ms. With eight slots a ripple is 0.88s old when it is reused. */
const GAP = 110;
const GOLDEN_ANGLE = 2.39996;

const LIVE = "(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)";

function subscribe(onChange: () => void) {
  const query = window.matchMedia(LIVE);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}

/** False on the server, on touch and under reduced motion, where no canvas is mounted at all. */
function useLive() {
  return React.useSyncExternalStore(
    subscribe,
    () => window.matchMedia(LIVE).matches,
    () => false,
  );
}

type RippleImageProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** The image. Same origin, or served with CORS headers, or it stays the plain image. */
  src: string;
  /** Alt text for the real image, which stays in the DOM. */
  alt: string;
  /** How far a ripple bends the image at its peak, in CSS pixels. */
  depth?: number;
  /** Seconds between soft drops while no pointer is over the image. 0 turns them off. */
  idle?: number;
};

/**
 * An image that takes a soft refractive ripple from the pointer. On touch and under reduced motion
 * no canvas mounts, so it is the plain image. The canvas only paints where a ripple is passing.
 */
function RippleImage({ src, alt, depth = 16, idle = 0, className, ...props }: RippleImageProps) {
  const root = React.useRef<HTMLDivElement>(null);
  const live = useLive();
  // x and y in CSS pixels from the bottom left, age in seconds, strength from 0 to 1.
  const [ripples] = React.useState(() => Array.from({ length: COUNT }, () => [0, 0, 0, 0]));
  const uniforms = React.useMemo(
    () => ({ uDepth: depth, ...Object.fromEntries(ripples.map((ripple, i) => [`uRipple[${i}]`, ripple])) }),
    [depth, ripples],
  );
  const textures = React.useMemo(() => ({ uImage: src }), [src]);

  React.useEffect(() => {
    const el = root.current;
    if (!live || !el) return;
    let frame = 0;
    let last = 0;
    let slot = 0;
    let turn = 0;
    let over = false;
    let seen = false;
    let previous = { x: -SPACING, y: -SPACING, time: 0 };

    const tick = (now: number) => {
      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      let alive = false;
      for (const ripple of ripples) {
        if (ripple[3] === 0) continue;
        ripple[2] += dt;
        if (ripple[2] > LIFE) ripple[3] = 0;
        else alive = true;
      }
      frame = alive ? requestAnimationFrame(tick) : 0;
      if (!frame) last = 0;
    };

    const drop = (x: number, y: number, strength: number) => {
      const ripple = ripples[slot];
      slot = (slot + 1) % COUNT;
      ripple[0] = x;
      ripple[1] = y;
      ripple[2] = 0;
      ripple[3] = strength;
      if (!frame) frame = requestAnimationFrame(tick);
    };

    const onMove = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      const box = el.getBoundingClientRect();
      const x = e.clientX - box.left;
      const y = box.bottom - e.clientY;
      const travel = Math.hypot(x - previous.x, y - previous.y);
      const elapsed = e.timeStamp - previous.time;
      if (travel < SPACING || elapsed < GAP) return;
      // Faster pointers hit harder, from 0.4 at a slow drift to 1 at 1.5px per ms.
      drop(x, y, Math.min(0.4 + (travel / elapsed) * 0.4, 1));
      previous = { x, y, time: e.timeStamp };
    };
    const onEnter = () => {
      over = true;
    };
    const onLeave = () => {
      over = false;
    };

    // Idle drops walk the golden angle, so no two land in the same place and no pattern shows.
    const timer =
      idle > 0
        ? window.setInterval(() => {
            if (over || !seen || document.hidden) return;
            turn += 1;
            const { width, height } = el.getBoundingClientRect();
            const swing = 0.16 + 0.16 * ((turn % 3) / 2);
            const x = width * (0.5 + swing * Math.cos(turn * GOLDEN_ANGLE));
            const y = height * (0.5 + swing * Math.sin(turn * GOLDEN_ANGLE));
            drop(x, y, 0.8);
          }, idle * 1000)
        : 0;
    const viewport = new IntersectionObserver(([entry]) => {
      seen = entry.isIntersecting;
    });
    viewport.observe(el);

    el.addEventListener("pointermove", onMove, { passive: true });
    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      if (frame) cancelAnimationFrame(frame);
      window.clearInterval(timer);
      viewport.disconnect();
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointerleave", onLeave);
    };
  }, [idle, live, ripples]);

  return (
    <div
      ref={root}
      data-slot="ripple-image"
      className={cn("relative isolate aspect-3/2 overflow-hidden bg-muted", className)}
      {...props}
    >
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img src={src} alt={alt} decoding="async" className="absolute inset-0 size-full object-cover" />
      {live ? <ShaderCanvas fragment={FRAGMENT} uniforms={uniforms} textures={textures} /> : null}
    </div>
  );
}

export { RippleImage };
components/ui/shader-canvas.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

type UniformValue = number | readonly number[];

type ShaderCanvasProps = React.ComponentProps<"div"> & {
  /** Fragment shader source. Receives uTime, uResolution (device px), uScale (device px per CSS px), uPointer and uLoaded, plus your uniforms and tokens. */
  fragment: string;
  /** Extra uniforms. Read every frame, so a tween can mutate this object in place. */
  uniforms?: Record<string, UniformValue>;
  /** Theme tokens passed as vec3 uniforms, for example { uInk: "--foreground" }. Re-read on theme change. */
  tokens?: Record<string, string>;
  /** Images passed as sampler2D uniforms, for example { uImage: "/photo.jpg" }. uImageSize-style sizes follow: uImage gets uImageSize. */
  textures?: Record<string, string>;
  /** Upper limit on device pixels per CSS pixel. */
  maxDpr?: number;
  /** The moment, in seconds, drawn as the still frame under reduced motion. */
  stillAt?: number;
  /** "demand" draws only when `invalidateRef` is called, for pieces that rest. The default never stops while visible. */
  frames?: "continuous" | "demand";
  /** Receives a function that draws one frame now. Pair it with frames="demand". */
  invalidateRef?: React.RefObject<(() => void) | null>;
  /** "track" feeds a smoothed pointer position to uPointer on devices with a mouse. */
  pointer?: "none" | "track";
};

const VERTEX = `attribute vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`;

/** Resolves a token through the cascade, so a scoped .light or .dark wrapper is respected. */
function readToken(host: HTMLElement, token: string): [number, number, number] {
  const probe = document.createElement("span");
  probe.style.color = `var(${token})`;
  probe.style.display = "none";
  host.appendChild(probe);
  const color = getComputedStyle(probe).color;
  probe.remove();
  const paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
  if (!paint) return [0, 0, 0];
  paint.fillStyle = color;
  paint.fillRect(0, 0, 1, 1);
  const [r, g, b] = paint.getImageData(0, 0, 1, 1).data;
  return [r / 255, g / 255, b / 255];
}

function compile(gl: WebGLRenderingContext, 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;
  console.error(gl.getShaderInfoLog(shader));
  gl.deleteShader(shader);
  return null;
}

/**
 * A raw WebGL fragment shader on a full-bleed canvas. It pauses off screen, draws one still frame
 * under reduced motion, follows the theme, and leaves its children showing when WebGL is missing.
 * Children are the static fallback, so give it something that reads on its own.
 */
function ShaderCanvas({
  fragment,
  uniforms,
  tokens,
  textures,
  maxDpr = 1.5,
  stillAt = 2,
  frames = "continuous",
  invalidateRef,
  pointer = "none",
  className,
  children,
  ...props
}: ShaderCanvasProps) {
  const host = React.useRef<HTMLDivElement>(null);
  const canvas = React.useRef<HTMLCanvasElement>(null);
  const latest = React.useRef({ uniforms, tokens });
  const textureKey = JSON.stringify(textures ?? {});
  const redraw = React.useRef<() => void>(() => {});
  const [ready, setReady] = React.useState(false);
  // Bumped when a lost context comes back, which rebuilds everything from scratch.
  const [generation, setGeneration] = React.useState(0);

  React.useEffect(() => {
    latest.current = { uniforms, tokens };
    redraw.current();
  });

  React.useEffect(() => {
    const root = host.current;
    const el = canvas.current;
    if (!root || !el) return;
    // WebGL clears its buffer once a frame is presented. A canvas that draws rarely has to keep it,
    // or the picture is gone the next time the browser re-composites the page.
    const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
    const gl = el.getContext("webgl", {
      preserveDrawingBuffer: motion.matches || frames === "demand",
      alpha: true,
      antialias: false,
      depth: false,
      stencil: false,
      premultipliedAlpha: true,
      powerPreference: "low-power",
    });
    if (!gl) return;

    const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
    const frag = compile(gl, gl.FRAGMENT_SHADER, fragment);
    const program = gl.createProgram();
    if (!vertex || !frag || !program) return;
    gl.attachShader(program, vertex);
    gl.attachShader(program, frag);
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      console.error(gl.getProgramInfoLog(program));
      return;
    }
    gl.useProgram(program);

    // One oversized triangle covers the clip square with no diagonal seam.
    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const position = gl.getAttribLocation(program, "aPosition");
    gl.enableVertexAttribArray(position);
    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

    const locations = new Map<string, WebGLUniformLocation | null>();
    const at = (name: string) => {
      if (!locations.has(name)) locations.set(name, gl.getUniformLocation(program, name));
      return locations.get(name) ?? null;
    };
    const set = (name: string, value: UniformValue) => {
      const loc = at(name);
      if (!loc) return;
      if (typeof value === "number") gl.uniform1f(loc, value);
      else if (value.length === 2) gl.uniform2f(loc, value[0], value[1]);
      else if (value.length === 3) gl.uniform3f(loc, value[0], value[1], value[2]);
      else if (value.length === 4) gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
    };

    let colors: Record<string, [number, number, number]> = {};
    const readTheme = () => {
      colors = Object.fromEntries(
        Object.entries(latest.current.tokens ?? {}).map(([name, token]) => [name, readToken(root, token)]),
      );
    };
    readTheme();

    // Textures load after the first frame. A sampler with nothing bound reads as black, so shaders that
    // use one should fade in on uLoaded, which goes from 0 to 1 once every image has arrived.
    const sources = Object.entries(JSON.parse(textureKey) as Record<string, string>);
    const images: WebGLTexture[] = [];
    let loaded = sources.length === 0 ? 1 : 0;
    let disposed = false;
    let pending = sources.length;
    sources.forEach(([name, src], unit) => {
      const texture = gl.createTexture();
      if (!texture) return;
      images.push(texture);
      const image = new Image();
      image.crossOrigin = "anonymous";
      image.onload = () => {
        if (disposed) return;
        gl.activeTexture(gl.TEXTURE0 + unit);
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
        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);
        const sampler = at(name);
        if (sampler) gl.uniform1i(sampler, unit);
        set(`${name}Size`, [image.naturalWidth, image.naturalHeight]);
        pending -= 1;
        if (pending === 0) loaded = 1;
        draw();
      };
      image.src = src;
    });

    const target = { x: 0.5, y: 0.5 };
    const eased = { x: 0.5, y: 0.5 };
    let time = stillAt;
    let visible = false;
    let frame = 0;
    let last = 0;

    const draw = () => {
      gl.viewport(0, 0, el.width, el.height);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      set("uTime", time);
      set("uResolution", [el.width, el.height]);
      set("uScale", el.width / Math.max(el.clientWidth, 1));
      set("uPointer", [eased.x, eased.y]);
      set("uLoaded", loaded);
      for (const [name, value] of Object.entries(colors)) set(name, value);
      for (const [name, value] of Object.entries(latest.current.uniforms ?? {})) set(name, value);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    };
    redraw.current = () => {
      if (motion.matches) draw();
    };

    const loop = (now: number) => {
      frame = 0;
      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      time += dt;
      // Frame-rate independent smoothing: 1 - e^(-k dt).
      const k = 1 - Math.exp(-6 * dt);
      eased.x += (target.x - eased.x) * k;
      eased.y += (target.y - eased.y) * k;
      draw();
      schedule();
    };
    const schedule = () => {
      if (frames === "demand" || frame || !visible || motion.matches || document.hidden || gl.isContextLost()) return;
      frame = requestAnimationFrame(loop);
    };
    const stop = () => {
      if (frame) cancelAnimationFrame(frame);
      frame = 0;
      last = 0;
    };

    const resize = () => {
      const scale = Math.min(window.devicePixelRatio || 1, maxDpr);
      const width = Math.max(1, Math.round(el.clientWidth * scale));
      const height = Math.max(1, Math.round(el.clientHeight * scale));
      if (el.width !== width || el.height !== height) {
        el.width = width;
        el.height = height;
      }
      draw();
    };
    const resizer = new ResizeObserver(resize);
    resizer.observe(el);

    const viewport = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (!visible) return stop();
      draw();
      schedule();
    });
    viewport.observe(el);

    // Smooth scrolling libraries flip classes on html while scrolling, so react only to the theme itself.
    const themeKey = () => {
      const html = document.documentElement;
      return `${html.classList.contains("dark")} ${html.classList.contains("light")} ${html.style.colorScheme}`;
    };
    let lastTheme = themeKey();
    const theme = new MutationObserver(() => {
      if (themeKey() === lastTheme) return;
      lastTheme = themeKey();
      readTheme();
      draw();
    });
    theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });

    const onVisibility = () => (document.hidden ? stop() : schedule());
    const onMotion = () => {
      stop();
      if (motion.matches) {
        time = stillAt;
        draw();
      } else schedule();
    };
    const onPointer = (e: PointerEvent) => {
      const box = el.getBoundingClientRect();
      target.x = (e.clientX - box.left) / box.width;
      target.y = 1 - (e.clientY - box.top) / box.height;
    };
    const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    const tracks = pointer === "track" && fine;
    document.addEventListener("visibilitychange", onVisibility);
    motion.addEventListener("change", onMotion);
    if (tracks) window.addEventListener("pointermove", onPointer, { passive: true });

    // preventDefault asks the browser to restore the context. Until then the static fallback shows.
    const onLost = (e: Event) => {
      e.preventDefault();
      stop();
      setReady(false);
    };
    const onRestored = () => setGeneration((n) => n + 1);
    el.addEventListener("webglcontextlost", onLost);
    el.addEventListener("webglcontextrestored", onRestored);

    if (invalidateRef) invalidateRef.current = draw;
    resize();
    setReady(true);
    return () => {
      disposed = true;
      if (invalidateRef) invalidateRef.current = null;
      stop();
      redraw.current = () => {};
      resizer.disconnect();
      viewport.disconnect();
      theme.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      motion.removeEventListener("change", onMotion);
      if (tracks) window.removeEventListener("pointermove", onPointer);
      el.removeEventListener("webglcontextlost", onLost);
      el.removeEventListener("webglcontextrestored", onRestored);
      images.forEach((texture) => gl.deleteTexture(texture));
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
      gl.deleteShader(vertex);
      gl.deleteShader(frag);
    };
  }, [fragment, frames, generation, invalidateRef, maxDpr, pointer, stillAt, textureKey]);

  return (
    <div
      ref={host}
      data-slot="shader-canvas"
      aria-hidden="true"
      className={cn("absolute inset-0", className)}
      {...props}
    >
      {ready ? null : children}
      <canvas ref={canvas} className="absolute inset-0 size-full" />
    </div>
  );
}

export { ShaderCanvas };

For AI agents

Hand your agent this page, the markdown at /components/ripple-image/prompt.md, or the Copy AI prompt button above.

When to use

  • One large image on a landing page that should answer the pointer
  • A case study or portfolio cover, where the image is the point
  • Beside short copy, with no controls on top of the image

When not to use

  • On a grid of thumbnails, since each one holds a WebGL context on a desktop
  • On charts, screenshots or anything people need to read exactly
  • As the only way to convey information, because touch users never see it

Agent notes

  • It defaults to a 3:2 box. Pass aspect-auto and a height to fill something else, and the shader crops the same way as object-cover
  • A real img with its alt is always in the DOM. On touch and under reduced motion no canvas mounts, so it is simply that image
  • The image must be same origin or send CORS headers. If the texture cannot load the canvas stays clear and the plain image shows
  • It installs components/ui/shader-canvas.tsx, and the canvas is aria-hidden. It uses no GSAP

The maths

  • Each ring is a sine inside a Gaussian envelope, sin(3.2x) × e^(-x²), which gives a soft ring with no hard front.
  • Strength decays as e^(-2.4t), so a ripple is at 12 percent after 0.88s and under 1 percent when it is dropped at 2.2s.
  • Eight slots with a 110ms gap mean a slot is reused at 0.88s at the earliest, too faint for the swap to show.
  • Idle drops step by the golden angle, 2.39996 radians, which never lands twice in the same place.