Introduction
This component is a rounded glass button with real water sitting inside it — not a looping video, not a CSS wave animation, but an actual small physics solver running every frame on a canvas. Drag across it and the surface tips and ripples away from the cursor; click it and it sloshes from one side to the other and back; leave it alone and it goes still and stops computing entirely until you touch it again.
The technique rests on five ideas: modeling the water as a set of vertical lanes each holding a height, instead of trying to animate a wave shape directly — moving water between neighboring lanes through a flow value so the total volume never changes no matter how hard it is stirred — separating the pointer's effect on the water (a stir) from a click's effect (a splash), since they are physically different kinds of push — letting fast-moving surface points occasionally tear off as ballistic droplets that get poured back into whichever lane they land in — and putting the whole simulation to sleep once it settles, so an idle button costs nothing on the page.
How a height-per-lane model is what allows water to visibly slosh and pile up against one side, which a per-point spring animation cannot do — how to move volume between lanes without ever creating or destroying it — how to tell a hover-drag apart from a click in the same physics update — how to spawn and reabsorb droplets so the "flying" water and the "resting" water always sum to the same total — and how to detect stillness and freeze a canvas loop instead of running requestAnimationFrame forever at idle.
Step 1 — A Frosted Layer With Something to Refract
The button itself is three stacked layers inside one rounded container: a backdrop-filter: blur() layer for the frosted glass look, a faint tint layer on top of that, and a canvas above both where the water is actually drawn. backdrop-filter needs visual detail behind it to blur — over a flat page background it reads as doing nothing, so the page uses a dark radial gradient rather than a solid color, giving the frost something to soften.
.liquid-btn {
position: relative;
border-radius: 999px;
isolation: isolate; /* keep child stacking contained to this button */
}
.liquid-btn .frost {
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(255,255,255,0.06);
backdrop-filter: blur(22px) saturate(140%);
}
.liquid-btn canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: inherit;
}A thin .rim div sits above the canvas as a masked gradient border, and the label sits above all three layers at z-index: 3 so it stays legible no matter how energetic the water underneath gets.
Step 2 — One Height Value Per Vertical Slice
The button width is divided into a fixed number of lanes — 56 by default — and the entire water state is just two arrays: height, one value per lane measuring how deep the water is in that slice, and flow, one value per boundary between lanes measuring how fast water is currently crossing from one lane into its neighbor. There is deliberately no per-pixel wave shape; the canvas draws a smooth line through these lane heights every frame instead of storing the shape itself.
class FluidPane {
constructor(canvas, host, opts = {}) {
this.lanes = opts.lanes ?? 56;
this.height = new Float32Array(this.lanes);
this.flow = new Float32Array(this.lanes + 1); // one more than lanes: the boundaries
this.restHeight = 0;
this.laneWidth = 1;
// ...pointer state, droplets, sizing
}
}There is no wave shape stored anywhere — just a height per lane. The wave is simply what you get when you draw a line through those numbers.
Step 3 — Water That Leaves One Lane Arrives in Another
Each substep, every interior boundary's flow accelerates based on the slope of the surface across it — water pushes from the taller neighbor toward the shorter one, the same way it would in a real basin. That flow is then damped slightly so ripples don't accelerate forever, and finally used to move volume: a lane's height goes down by exactly however much flowed out through its two boundaries, and its neighbor's height goes up by exactly that much. Nothing is invented and nothing is discarded, which is what keeps a wildly stirred button from visibly gaining or losing water.
for (let i = 1; i < this.lanes; i++) {
const slope = (this.height[i] - this.height[i - 1]) / this.laneWidth;
this.flow[i] += -this.gravity * slope * sub;
this.flow[i] *= Math.max(0, 1 - this.damping * sub);
}
this.flow[0] = 0;
this.flow[this.lanes] = 0; // the two edges never flow through a wall
for (let i = 0; i < this.lanes; i++) {
const inflow = this.flow[i] * (this.flow[i] > 0 ? this.height[i - 1] ?? this.height[i] : this.height[i]);
const outflow = this.flow[i + 1] * (this.flow[i + 1] > 0 ? this.height[i] : this.height[i + 1] ?? this.height[i]);
this.height[i] -= ((outflow - inflow) / this.laneWidth) * sub;
if (this.height[i] < 0) this.height[i] = 0;
}This step runs five substeps per frame instead of one. A shallow-water update like this is only stable if a wave can't cross more than about one lane per step — running it in smaller substeps keeps the simulation calm even when the pointer whips across the button quickly.
Step 4 — Two Different Pushes for Two Different Gestures
A hover-drag and a click are physically different inputs, so they are kept as two separate methods rather than one generic "apply force" function. _stir() runs every substep while the pointer is over the button and nudges only the lanes near the cursor's current position, scaled by how fast the pointer is moving. _splash() runs once, on pointerdown, and pushes a symmetric burst outward from a single point — closer to how an actual finger tapping the surface would displace water in both directions at once.
_splash(x, strength) {
const reach = 46;
const first = Math.max(1, Math.floor((x - reach) / this.laneWidth));
const last = Math.min(this.lanes - 1, Math.ceil((x + reach) / this.laneWidth));
for (let i = first; i <= last; i++) {
const dist = i * this.laneWidth - x;
const falloff = 1 - Math.abs(dist) / reach;
if (falloff <= 0) continue;
this.flow[i] += Math.sign(dist || 1) * strength * falloff;
}
}Step 5 — A Height Field Cannot Break, So Fast Water Leaves It
A single height-per-lane model can never fold over or fly apart — by construction there is exactly one surface height at every x position. So instead of trying to make the height field itself spray, any lane whose flow crosses a speed threshold has a small chance each frame of tearing off a droplet: a tiny amount of height is subtracted from that lane and handed to a plain ballistic point with its own velocity. Droplets fall under gravity independently of the water solver, and the moment one lands back at or below the surface height of the lane underneath it, its volume is added back into that lane's height and the droplet is removed. The two halves — flying droplets and resting water — always add up to the same total volume.
for (let i = 2; i < this.lanes - 2; i++) {
if (Math.abs(this.flow[i]) < 260) continue;
if (Math.random() > 0.06) continue;
this.droplets.push({
x: i * this.laneWidth,
y: this.heightPx - this.height[i] - 2,
vx: this.flow[i] * 0.06,
vy: -220 - Math.random() * 100,
});
this.height[i] = Math.max(0, this.height[i] - 1.2);
}Step 6 — Stop Computing Once the Water Is Actually Still
A button that always runs requestAnimationFrame costs battery and CPU forever, even sitting untouched on a page nobody is looking at. Every frame, the loop checks the worst deviation of any lane from its resting height, plus whether any droplets are still in flight. Once that "restlessness" score stays below a tiny threshold for about two dozen consecutive frames with the pointer away from the button, the simulation snaps every lane back to a perfectly flat rest height, clears any stray droplets, and flips a settled flag that skips the physics update entirely — the canvas simply stops changing until a pointerenter or pointermove wakes it back up.
const restless = this._measureRestlessness();
if (restless < 0.06 && this.pointer.x < -1000) {
this.stillFrames++;
} else {
this.stillFrames = 0;
}
if (this.stillFrames > 24) {
this.height.fill(this.restHeight);
this.flow.fill(0);
this.droplets.length = 0;
this.settled = true;
}Step 7 — Draw the Lane Line, Then Fill It Flat
Drawing is a single closed path: trace a line across the top of every lane's height, then close the shape down to the bottom and both sides of the canvas. Earlier drafts filled that shape with a top-to-bottom gradient to fake depth, but the current version fills it with one flat color and only keeps a lighter stroke along the surface line itself for a hint of shine — a simpler, calmer look that also happens to be cheaper to paint every frame since there is no gradient to rebuild.
ctx.beginPath();
ctx.moveTo(0, h - this.height[0]);
for (let i = 0; i < this.lanes; i++) {
ctx.lineTo(i * this.laneWidth + this.laneWidth / 2, h - this.height[i]);
}
ctx.lineTo(w, h - this.height[this.lanes - 1]);
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = this.colorMid; // one flat color, no gradient
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.65)';
ctx.lineWidth = 1.4;
ctx.stroke(); // just the surface line gets a highlightA ResizeObserver rebuilds the lane width and canvas resolution whenever the button's box changes, and a small interval checks the effective device-pixel ratio every 300ms to catch the button being dragged between a normal and a high-density display without needing a dedicated event for it.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| lanes | 56 | How many vertical slices the water is divided into; more lanes read as smoother water but cost more per frame |
| gravity | 5400 | How hard the surface slope accelerates flow; higher values slosh back and forth faster and more sharply |
| damping | 3.1 | How quickly flow bleeds off each substep; too low and ripples ring forever, too high and the water feels like syrup |
| stir reach / splash reach | 42px / 46px | How wide an area around the pointer gets pushed; wider reach feels like a bigger hand in the water |
| droplet tear threshold | 260 | Minimum |flow| before a lane has a chance to spray a droplet; lower values make the button spray more eagerly |
| stillFrames threshold | 24 | How many calm frames before the sim freezes and sleeps; lower saves more CPU but risks freezing mid-ripple |
Full Source Code
The complete component is a single self-contained HTML document — the glass button markup, the FluidPane class handling lanes, flow, droplets, sleep, and rendering, and nothing else. No build step, no external assets.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/liquid-button.html