Bubble Rise
Bubbles climbing with a wobble, swelling a little as the pressure above them drops.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Bubble Rise
*
* Bubbles climbing with a wobble, swelling a little as the pressure
* above them drops.
*
* The technique that stops it looking like snow running backwards:
* wobble frequency is tied inversely to radius. Small bubbles jitter
* fast and tight; big ones sway slowly and wide, and because rise speed
* grows with radius too, the biggest bubbles are simultaneously the
* fastest climbers and the laziest wobblers. Size and motion are locked
* to each other, so the field reads as one body of water with things of
* different sizes in it, rather than as particles that happen to differ.
*
* Supporting detail: a bubble is a rim and a highlight, never a filled
* disc. A filled disc is a dot no matter how it moves.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type BubbleRiseProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Bubbles in the water at once. */
count?: number;
/** Rim and highlight colour. */
color?: string;
/** Fires once a bubble has reached the surface. */
onBubbleSurfaced?: () => void;
};
type VariantConfig = {
/** Multiplier applied to `count`. */
density: number;
/** Rise speed in px per second for a mid-sized bubble. */
rise: number;
/** Wobble constant. Divided by radius to get each bubble's rate. */
wobble: number;
/** Mid bubble radius in px. */
size: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A few bubbles, barely moving — a glass sitting still.
subtle: { density: 0.7, rise: 22, wobble: 20, size: 5 },
// Reads as carbonation at a glance. All-purpose.
default: { density: 1, rise: 34, wobble: 26, size: 6 },
// A fresh pour: more of them, climbing faster and swinging wider.
playful: { density: 1.4, rise: 50, wobble: 32, size: 7 },
};
type Bubble = {
/** The line the bubble climbs; the wobble is measured from it. */
lane: number;
y: number;
/** Radius at the bottom, before it expands on the way up. */
seed: number;
phase: number;
/** Per-bubble speed variation. */
drift: number;
};
/** How much a bubble grows over a full climb, as a fraction of its radius. */
const GROWTH = 0.34;
export default function BubbleRise({
variant = "default",
count = 28,
color = "#8FD3E8",
onBubbleSurfaced,
}: BubbleRiseProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Callbacks are read through a ref so an inline arrow from the parent
// can't restart the field on every render.
const surfacedRef = useRef(onBubbleSurfaced);
useEffect(() => {
surfacedRef.current = onBubbleSurfaced;
}, [onBubbleSurfaced]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const total = Math.max(4, Math.round(count * config.density));
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let ratio = 1;
const resize = () => {
const rect = canvas.getBoundingClientRect();
ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
};
// Measured before the first bubble is placed: spawning against a
// zero-size canvas piles the whole field into one corner.
resize();
const random = (min: number, max: number) => min + Math.random() * (max - min);
const spawn = (initial: boolean, index = 0): Bubble => ({
lane: random(0.04, 0.96) * width,
// The opening fill is stratified up the column rather than purely
// random, so the first frame is already evenly seeded.
y: initial ? ((index + Math.random()) / total) * height : height + random(4, 40),
seed: config.size * random(0.42, 1.35),
phase: random(0, Math.PI * 2),
drift: random(0.82, 1.2),
});
let bubbles = Array.from({ length: total }, (_, index) => spawn(true, index));
/** Radius now: it swells as the water above it thins out. */
const radiusOf = (bubble: Bubble) =>
bubble.seed * (1 + GROWTH * (1 - Math.max(0, Math.min(1, bubble.y / Math.max(height, 1)))));
const drawFrame = (elapsed: number) => {
context.clearRect(0, 0, width, height);
context.strokeStyle = color;
context.fillStyle = color;
for (const bubble of bubbles) {
const radius = radiusOf(bubble);
// Big bubbles sway slowly and wide, small ones shiver.
const rate = config.wobble / radius;
const amplitude = 1.4 + radius * 0.45;
const x = bubble.lane + Math.sin(elapsed * rate + bubble.phase) * amplitude;
// Thin out at the surface rather than vanishing at the edge.
const fade = Math.min(1, bubble.y / Math.max(1, height * 0.16));
context.lineWidth = Math.max(0.6, radius * 0.16);
context.globalAlpha = 0.42 * fade;
context.beginPath();
context.arc(x, bubble.y, radius, 0, Math.PI * 2);
context.stroke();
// Barely-there body, so it holds a shape against a busy backdrop.
context.globalAlpha = 0.07 * fade;
context.fill();
// One specular point up and to the left: the detail that makes
// a ring read as a bubble instead of an O.
if (radius > 2.4) {
context.globalAlpha = 0.55 * fade;
context.beginPath();
context.arc(x - radius * 0.33, bubble.y - radius * 0.36, radius * 0.2, 0, Math.PI * 2);
context.fill();
}
}
context.globalAlpha = 1;
};
// Reduced motion: one frame, held. Bubbles at different heights are
// at different sizes, so the still already shows the expansion.
if (reduced) {
drawFrame(0);
const onResizeStill = () => {
resize();
bubbles = Array.from({ length: total }, (_, index) => spawn(true, index));
drawFrame(0);
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let last = performance.now();
let elapsed = 0;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
for (const bubble of bubbles) {
const radius = radiusOf(bubble);
// Bigger bubbles climb faster, which is the other half of the
// size-to-motion coupling.
const rise = config.rise * (0.5 + radius / config.size / 2) * bubble.drift;
bubble.y -= rise * delta;
if (bubble.y + radius < 0) {
surfacedRef.current?.();
Object.assign(bubble, spawn(false));
}
}
drawFrame(elapsed);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
bubbles = Array.from({ length: total }, (_, index) => spawn(true, index));
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block", pointerEvents: "none" }}
/>
);
}About this effect
Water behind a screen — a drinks order, a hydration tracker, a soft loading state that should feel light. Wobble frequency is tied inversely to radius, so small bubbles shiver fast and tight while large ones sway slowly and wide; because rise speed grows with radius too, the biggest bubbles are at once the fastest climbers and the laziest wobblers. Size and motion are locked to each other, which is what stops the field looking like snow running backwards. Each bubble is drawn as a rim with one specular highlight up and to the left rather than as a filled disc, and it swells on the way up as the water above it thins.