# Bubbles

> 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.

Category: Backgrounds · Free · Updated 2026-10-30 · Live demo: https://www.motionkitui.com/components/bubbles

## When to use it

Playful consumer apps, kids' brands, celebrations, waiting screens and any hero that should make people smile.

## How it works

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.

## Installation

```bash
npx shadcn@latest add https://www.motionkitui.com/r/bubbles.json
```

Or copy `components/motionkit/bubbles.tsx` below. Requires react and Tailwind CSS v4. Free for personal projects; commercial use needs MotionKit Plus (https://www.motionkitui.com/pricing).

## Props

| 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. |

## Accessibility

- **Keyboard: Not applicable.** Decorative, nothing to focus.
- **Screen readers: Pass.** The canvas is hidden from assistive tech; only your content is read.
- **Reduced motion: Pass.** With reduced motion on, the bubbles hold still and don't pop. It also stops drawing off screen.
- **Colour contrast: Pass.** Bubbles are mostly transparent, so ivory text measures 7.8:1 or better even with one behind it.

## Source

```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>
  );
}
```
