Axistifyopen source

liquid-ripple

A water background that reacts to the pointer — ambient swell, caustic shimmer, and ripple rings that spread and fade where you move. One WebGL fragment shader, zero dependencies.

You are looking at it. It runs behind this heading, and behind our book-a-demo page, which is where it was born.

View on GitHubTry the playgroundMIT · ~6 KB · no deps

Install

npm install liquid-ripple

React is an optional peer dependency — you only need it for the liquid-ripple/react entry point. Nothing else is pulled in: no three.js, no react-three-fiber, no build step.

Prefer to vendor it? Copy src/ from the repo into your project — it is four files and nothing else. The published tarball ships bundled ES2020 ESM plus types, and the source, so either route works.

Quick start

Two rules carry the whole integration. Give the container a CSS gradient — the library renders nothing under reduced motion or without WebGL, and that gradient is the fallback. Let the library own the canvas size — size its CSS box and never set the width/height attributes yourself.

One trap worth naming: a canvas is a replaced element, so position:absolute; inset:0 does not stretch it — it keeps its intrinsic 300×150 and you get a small patch of water in the corner. Add width:100%; height:100%. The React component already does this for you.

React (and Next.js — the entry point is already a client component)

import { LiquidRipple } from "liquid-ripple/react";

export default function Hero() {
  return (
    <section className="relative isolate min-h-screen overflow-hidden
                        bg-gradient-to-b from-[#8cded1] to-[#0a8073]">
      <LiquidRipple palette="lagoon" />
      <div className="relative z-10">…your content…</div>
    </section>
  );
}

Vanilla JS

<canvas id="water" style="position:fixed;inset:0;width:100%;height:100%"></canvas>
import { createLiquidRipple } from "liquid-ripple";

const water = createLiquidRipple(document.getElementById("water"), {
  palette: "deep",
});

// null means "we deliberately did not render" — reduced motion, or no WebGL.
// Your CSS background is the fallback; there is nothing to handle.
if (water) water.drop(innerWidth / 2, innerHeight / 2);

Playground

Every visual knob is a shader uniform, so these controls change the running effect in place — nothing recompiles and no context is re-created. The snippet updates as you drag.

Move your pointer across the water

Palette
Wave
1
0.34
0.32
1.5
60
0.12
0.3
0.85

Your config

<LiquidRipple
  palette="lagoon"
/>

API

createLiquidRipple(canvas, options?) → LiquidRipple | null

Returns null — after calling onUnsupported — when the effect must not or cannot run: server-side rendering, prefers-reduced-motion, no WebGL, or a shader failure. That is a normal outcome, not an error.

Options

OptionTypeDefault
palettePaletteName | Palette"deep"Built-in name, or your own { top, bottom, glint } colors.
wavePartial<WaveOptions>see belowThe water's physical feel.
maxRipplesnumber24Concurrent ripples. Baked into the shader, so it cannot change later.
interactiveboolean | "window" | "canvas"trueWhere pointer movement spawns ripples. true means anywhere on the page.
ambientnumber2300Milliseconds between self-spawned drops, so the water is never still. 0 disables.
pointerThrottlenumber110Minimum milliseconds between pointer-spawned ripples.
dprCapnumber1.5Upper bound on devicePixelRatio. It is a background — full retina is wasted work.
speednumber1Time multiplier. 0.5 is half speed.
opacitynumber1Below 1 the canvas is composited over what is behind it.
respectReducedMotionbooleantrueRender nothing when the user asks for less motion. Leave it on.
onUnsupported(reason) => void"reduced-motion" | "no-webgl" | "shader-error". Use it to reveal a fallback.

WaveOptions

All nine are live-updatable through setWave().

FieldDefault
swell1Ambient wave height. 0 is glass-still water.
amplitude0.34Height of a ripple crest at birth.
ringSpeed0.32How fast a ring expands, in screen heights per second.
decay1.5Envelope fade rate. Higher means ripples die sooner.
wavelength60Ripples per unit distance inside a ring. Higher is finer.
life5Seconds a ripple stays in the height field.
refraction0.12How hard the surface bends the background gradient.
caustics0.3Strength of the caustic shimmer.
specular0.85Strength of the glint on the crests.

Handle

interface LiquidRipple {
  canvas: HTMLCanvasElement;
  drop(x, y, space?: "client" | "canvas" | "uv"): void;
  setPalette(palette): void;
  setWave(wave): void;
  setSpeed(speed): void;
  pause(): void;    // stops the loop and ambient drops
  resume(): void;   // time picks up where it left off
  destroy(): void;  // removes listeners, frees GL objects, drops the context
}

All three coordinate spaces put the origin at the top-left with y down, like the DOM: client is viewport coordinates (straight from a pointer event), canvas is CSS pixels relative to the canvas, uv is 0..1 fractions.

React props

<LiquidRipple /> takes every option above, plus className, style, fill (absolutely fill the positioned ancestor, default true) and onReady (the handle, or null if the effect declined). Changing palette, wave or speed updates the live effect; changing a structural option re-creates it.

Accessibility

  • With prefers-reduced-motion: reduce, nothing renders and nothing animates. The library returns null and leaves your CSS background alone, so reduced-motion visitors get a clean static page rather than a degraded animation.
  • The canvas is aria-hidden="true". It is decoration and is never announced.
  • Nothing steals focus, and pointer listeners are passive, so scrolling and touch gestures are never blocked.
  • The effect adds no contrast requirements of its own — but check your text against the lightest moment of the animation, not the average.

Performance

One draw call per frame over a single full-screen triangle. No geometry, no textures, no render targets, and no allocation inside the render loop.

  • Device pixel ratio is capped at 1.5 — invisible on a soft background, and it saves more than half the fragment work on a retina display.
  • Nothing is drawn while the tab is hidden, and the clock is clamped so a backgrounded tab never fast-forwards the water on return.
  • pause() cancels the loop entirely. Pair it with an IntersectionObserver — as the playground above does — for a background that only costs anything while it is on screen.
  • Ripples live in a fixed 24-slot ring buffer, so shader cost is constant no matter how fast the pointer moves.
  • WebGL context loss is handled: the program is rebuilt and the loop resumes.

Browser support is anything with WebGL 1 — every current version of Chrome, Edge, Firefox and Safari, desktop and mobile. No WebGL 2 features, no extensions.

How it works

The whole effect is one fragment shader evaluated over a single oversized triangle. Per pixel:

  1. 1Height field. Two slow crossing sine swells keep the surface alive. Each live ripple adds a damped sine ring: a wave packet tight around a radius that grows with age, its envelope fading as exp(-age · decay).
  2. 2Normals. The field is sampled three times — at the pixel and two tiny offsets — and the finite differences become a surface normal. This is the step that makes it read as water instead of circles drawn on a gradient.
  3. 3Refraction. The normal offsets the lookup into the vertical gradient, so the surface appears to bend the water beneath it.
  4. 4Light. A caustic term (crossing sines raised to a high power) plus a tight specular lobe against a fixed light direction, both tinted by glint.

Live ripples reach the shader as a vec3[24] uniform (x, y, spawn time) written from a JavaScript ring buffer — so spawning one costs three float writes and no GL state change.

Technology

TypeScript · WebGL 1 · GLSL ES 1.00 · requestAnimationFrame · ResizeObserver · Pointer Events. No runtime dependencies. A scene-graph library would be pure overhead for a single full-screen shader, which is why there is no three.js here.