Install
npm install liquid-rippleReact 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
Your config
<LiquidRipple palette="lagoon" />
API
createLiquidRipple(canvas, options?) → LiquidRipple | nullReturns 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
| Option | Type | Default | |
|---|---|---|---|
palette | PaletteName | Palette | "deep" | Built-in name, or your own { top, bottom, glint } colors. |
wave | Partial<WaveOptions> | see below | The water's physical feel. |
maxRipples | number | 24 | Concurrent ripples. Baked into the shader, so it cannot change later. |
interactive | boolean | "window" | "canvas" | true | Where pointer movement spawns ripples. true means anywhere on the page. |
ambient | number | 2300 | Milliseconds between self-spawned drops, so the water is never still. 0 disables. |
pointerThrottle | number | 110 | Minimum milliseconds between pointer-spawned ripples. |
dprCap | number | 1.5 | Upper bound on devicePixelRatio. It is a background — full retina is wasted work. |
speed | number | 1 | Time multiplier. 0.5 is half speed. |
opacity | number | 1 | Below 1 the canvas is composited over what is behind it. |
respectReducedMotion | boolean | true | Render 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().
| Field | Default | |
|---|---|---|
swell | 1 | Ambient wave height. 0 is glass-still water. |
amplitude | 0.34 | Height of a ripple crest at birth. |
ringSpeed | 0.32 | How fast a ring expands, in screen heights per second. |
decay | 1.5 | Envelope fade rate. Higher means ripples die sooner. |
wavelength | 60 | Ripples per unit distance inside a ring. Higher is finer. |
life | 5 | Seconds a ripple stays in the height field. |
refraction | 0.12 | How hard the surface bends the background gradient. |
caustics | 0.3 | Strength of the caustic shimmer. |
specular | 0.85 | Strength 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 returnsnulland 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 anIntersectionObserver— 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:
- 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). - 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.
- 3Refraction. The normal offsets the lookup into the vertical gradient, so the surface appears to bend the water beneath it.
- 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.