Soap bubbles rising and wobbling, their rims shimmering like a film of soap. Touch one with the cursor and it pops in a spray of droplets.
Pop one
Customize
Usage
import { Bubbles } from "@/components/motionkit/bubbles";
<Bubbles className="h-[32rem] rounded-3xl">
<h1>Your hero</h1>
</Bubbles>Motion
The exact curves this component runs, plotted with Motion's own maths.
Rise and sway
Runs on its own
Pop
Pointer touches a bubble
Playful consumer apps, kids' brands, celebrations, waiting screens and any hero that should make people smile.
Each bubble rises at its own rate, smaller ones faster, while swaying on a sine wave. Its rim is a stroke with a linear gradient through your colours whose angle turns slowly, which reads as a soap film catching the light, plus a faint fill and a curved highlight. Moving the pointer through a bubble pops it: a ring expands and eight droplets fly out and fall, and a fresh bubble starts rising from below.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://www.motionkitui.com/r/bubbles.jsonnpm install motionSave as components/motionkit/bubbles.tsx.
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type BubblesProps = {
children?: ReactNode;
/** Colours the bubbles' rims shimmer through. */
colors?: string[];
/** How many bubbles are in the air at once. */
count?: number;
/** Rising speed. 0 holds them still. */
speed?: number;
/** Pop bubbles the pointer touches. */
poppable?: boolean;
className?: string;
};
type Bubble = { x: number; y: number; r: number; drift: number; phase: number; rate: number; hue: number };
type Pop = { x: number; y: number; r: number; age: number; color: string };
/**
* Soap bubbles rising and wobbling, their rims shimmering through your colours like a film of soap. Touch one
* with the pointer and it pops with a ring and a spray of droplets.
*/
export function Bubbles({ children, colors = ["#93CACE", "#5FAEB4", "#E4DCCF", "#A56D5B"], count = 18, speed = 1, poppable = true, className = "" }: BubblesProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
const area = canvas?.parentElement;
if (!canvas || !context || !area) return;
const still = speed === 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let frame = 0;
let visible = true;
let last = 0;
let seed = 7;
// a small seeded generator, so the first frame (and the still frame) is the same every time
const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
const spawn = (anywhere: boolean): Bubble => {
const r = 10 + random() * 34;
return { x: random() * width, y: anywhere ? random() * height : height + r + random() * 80, r, drift: 10 + random() * 24, phase: random() * Math.PI * 2, rate: 0.6 + random() * 0.7, hue: random() };
};
let bubbles: Bubble[] = [];
const pops: Pop[] = [];
function resize() {
const rect = canvas!.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas!.width = width * ratio;
canvas!.height = height * ratio;
context!.setTransform(ratio, 0, 0, ratio, 0, 0);
if (!bubbles.length) bubbles = Array.from({ length: count }, () => spawn(true));
}
function drawBubble(bubble: Bubble, time: number) {
const x = bubble.x + Math.sin(time * 0.6 * bubble.rate + bubble.phase) * bubble.drift;
// bubbles fade as they pass through the middle, where your content sits, so text stays readable
const cx = x / width - 0.5;
const cy = bubble.y / height - 0.5;
const calm = 0.15 + 0.85 * Math.min(1, (cx * cx * 1.4 + cy * cy) * 5);
// the rim's colours turn slowly, which reads as a soap film catching the light
const turn = time * 0.4 + bubble.hue * Math.PI * 2;
const rim = context!.createLinearGradient(x + Math.cos(turn) * bubble.r, bubble.y + Math.sin(turn) * bubble.r, x - Math.cos(turn) * bubble.r, bubble.y - Math.sin(turn) * bubble.r);
colors.forEach((color, index) => rim.addColorStop(index / Math.max(1, colors.length - 1), color));
const body = context!.createRadialGradient(x, bubble.y, bubble.r * 0.6, x, bubble.y, bubble.r);
body.addColorStop(0, "rgb(246 241 232 / 0)");
body.addColorStop(1, "rgb(246 241 232 / 0.08)");
context!.globalAlpha = calm;
context!.fillStyle = body;
context!.beginPath();
context!.arc(x, bubble.y, bubble.r, 0, Math.PI * 2);
context!.fill();
context!.globalAlpha = 0.75 * calm;
context!.lineWidth = 1.4;
context!.strokeStyle = rim;
context!.stroke();
// the window reflection, high on the left
context!.globalAlpha = 0.6 * calm;
context!.strokeStyle = "#F6F1E8";
context!.lineWidth = Math.max(1, bubble.r * 0.08);
context!.lineCap = "round";
context!.beginPath();
context!.arc(x, bubble.y, bubble.r * 0.72, Math.PI * 1.1, Math.PI * 1.35);
context!.stroke();
context!.globalAlpha = 1;
return x;
}
function draw(time: number) {
const delta = last ? Math.min(0.05, (time - last) / 1000) : 0;
last = time;
const seconds = time / 1000;
context!.clearRect(0, 0, width, height);
bubbles.forEach((bubble, index) => {
bubble.y -= delta * speed * 38 * bubble.rate * (1.2 - bubble.r / 60);
if (bubble.y < -bubble.r - 10) bubbles[index] = spawn(false);
drawBubble(bubble, seconds);
});
for (let index = pops.length - 1; index >= 0; index--) {
const pop = pops[index];
pop.age += delta * 2.2;
if (pop.age >= 1) {
pops.splice(index, 1);
continue;
}
context!.globalAlpha = 1 - pop.age;
context!.strokeStyle = pop.color;
context!.lineWidth = 1.2;
context!.beginPath();
context!.arc(pop.x, pop.y, pop.r * (1 + pop.age * 0.6), 0, Math.PI * 2);
context!.stroke();
// eight droplets flung outwards
context!.fillStyle = pop.color;
for (let drop = 0; drop < 8; drop++) {
const angle = (drop / 8) * Math.PI * 2 + pop.r;
const reach = pop.r * (1 + pop.age * 1.4);
context!.beginPath();
context!.arc(pop.x + Math.cos(angle) * reach, pop.y + Math.sin(angle) * reach + pop.age * pop.age * 30, 1.6 * (1 - pop.age), 0, Math.PI * 2);
context!.fill();
}
context!.globalAlpha = 1;
}
}
function loop(time: number) {
draw(time);
frame = visible ? requestAnimationFrame(loop) : 0;
if (!frame) last = 0;
}
resize();
draw(0);
if (!still) frame = requestAnimationFrame(loop);
const resizeObserver = new ResizeObserver(() => {
resize();
draw(performance.now());
});
resizeObserver.observe(canvas);
const visibilityObserver = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible && !frame && !still) frame = requestAnimationFrame(loop);
});
visibilityObserver.observe(canvas);
function move(event: PointerEvent) {
if (!poppable || still) return;
const rect = canvas!.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const seconds = performance.now() / 1000;
bubbles.forEach((bubble, index) => {
const x = bubble.x + Math.sin(seconds * 0.6 * bubble.rate + bubble.phase) * bubble.drift;
if (Math.hypot(px - x, py - bubble.y) < bubble.r) {
pops.push({ x, y: bubble.y, r: bubble.r, age: 0, color: colors[Math.floor(bubble.hue * colors.length) % colors.length] });
bubbles[index] = spawn(false);
}
});
}
area.addEventListener("pointermove", move);
area.addEventListener("pointerdown", move);
return () => {
cancelAnimationFrame(frame);
resizeObserver.disconnect();
visibilityObserver.disconnect();
area.removeEventListener("pointermove", move);
area.removeEventListener("pointerdown", move);
};
// the colours are compared by value, so a new array with the same colours doesn't restart it
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [colors.join(), count, speed, poppable]);
return (
<div className={`relative isolate overflow-hidden bg-[#0C1010] ${className}`}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 -z-10 size-full" />
{children}
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | string[] | — | Colours the rims shimmer through. |
| count | number | 18 | Bubbles in the air at once. |
| speed | number | 1 | Rising speed. 0 holds them still. |
| poppable | boolean | true | Pop bubbles the pointer touches. |
| children | ReactNode | — | Your content, layered above. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Nothing beyond React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://www.motionkitui.com/r/bubbles.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, the bubbles hold still and don't pop. It also stops drawing off screen.
Flicker Grid
A field of tiny squares that flicker softly, drawn on a canvas so it stays light.
Cursor Trail
A soft ribbon of light behind the pointer, across the window or inside one section.
Infinite Perspective Grid
A true 3D floor of glowing lines that recedes forever to a lit horizon, with an optional ceiling for a tunnel.