Account

Originals

Free

Bubble Wrap

Every bubble pops with a squash and a puff, and the sheet can be refilled. For loading waits, empty states, 404 pages and celebrations.

Install

No key needed.

Terminal
npx @galactrex/ui add bubble-wrap

Usage

Example
import { BubbleWrap } from "@/components/galactrex/bubble-wrap";

<BubbleWrap columns={10} rows={3} onComplete={unlock} />

Source

Installs to components/galactrex/bubble-wrap.tsx, with the shared motion primitives beside it.

components/galactrex/bubble-wrap.tsx
"use client";
import { useState } from "react";
import { motion } from "motion/react";
import { cx } from "@/components/galactrex/motion/cx";
import { curves } from "@/components/galactrex/motion/springs";
import { useReducedMotion } from "@/components/galactrex/motion/use-reduced-motion";

export type BubbleWrapProps = {
  /** bubbles across */
  columns?: number;
  /** bubbles down */
  rows?: number;
  /** bubble diameter in px */
  size?: number;
  /** called with how many are popped after each pop */
  onPop?: (popped: number, total: number) => void;
  /** called once when every bubble is popped */
  onComplete?: () => void;
  className?: string;
};

/** A sheet of bubble wrap. Every bubble pops with a squash and a puff, and the sheet can be refilled. Pure fidget
    for loading waits, empty states, 404 pages and celebrations, and a satisfying way to make people stay. Each
    bubble is a toggle button, so it works from the keyboard and reports how many are left. */
export function BubbleWrap({ columns = 8, rows = 4, size = 34, onPop, onComplete, className }: BubbleWrapProps) {
  const total = columns * rows;
  const reduced = useReducedMotion();
  const [popped, setPopped] = useState<Set<number>>(() => new Set());
  const pop = (i: number) => {
    if (popped.has(i)) return;
    const next = new Set(popped).add(i);
    setPopped(next);
    onPop?.(next.size, total);
    if (next.size === total) onComplete?.();
  };
  return (
    <div className={cx("inline-flex flex-col items-center gap-3 text-gxui-ink", className)}>
      <div role="group" aria-label={`Bubble wrap, ${total - popped.size} of ${total} left`} className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${columns}, ${size}px)` }}>
        {Array.from({ length: total }, (_, i) => {
          const done = popped.has(i);
          return (
            <button
              key={i}
              type="button"
              aria-pressed={done}
              aria-label={done ? "Popped" : "Pop"}
              onClick={() => pop(i)}
              className="relative rounded-full outline-none focus-visible:ring-2 focus-visible:ring-gxui-ink"
              style={{ width: size, height: size }}
            >
              <motion.span
                aria-hidden
                className={cx(
                  "absolute inset-0 rounded-full border",
                  done ? "border-gxui-line bg-transparent" : "border-gxui-line bg-[radial-gradient(circle_at_32%_28%,var(--gxui-surface),var(--gxui-raised)_60%)] shadow-[inset_0_-3px_5px_rgb(0_0_0/0.12)]",
                )}
                initial={false}
                animate={done ? { scaleX: [1, 1.18, 0.86], scaleY: [1, 0.72, 0.86] } : { scaleX: 1, scaleY: 1 }}
                whileHover={done || reduced ? undefined : { scale: 1.04 }}
                whileTap={done || reduced ? undefined : { scale: 0.9 }}
                transition={{ duration: reduced ? 0 : 0.18 }}
              />
              {done && !reduced && (
                <motion.span
                  aria-hidden
                  className="pointer-events-none absolute inset-0 rounded-full border-2 border-gxui-ink"
                  initial={{ scale: 0.6, opacity: 0.6 }}
                  animate={{ scale: 1.7, opacity: 0 }}
                  transition={{ duration: 0.35, ease: curves.glide }}
                />
              )}
            </button>
          );
        })}
      </div>
      <div className="flex items-center gap-3 font-mono text-xs text-gxui-muted">
        <span aria-live="polite">{popped.size === total ? "All popped." : `${popped.size} / ${total}`}</span>
        {popped.size > 0 && (
          <button type="button" onClick={() => setPopped(new Set())} className="rounded underline underline-offset-4 outline-none hover:text-gxui-ink focus-visible:ring-2 focus-visible:ring-gxui-ink">
            Refill
          </button>
        )}
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
columns, rowsnumber8, 4Bubbles across and down.
sizenumber34Bubble diameter in px.
onPop(popped: number, total: number) => void—After each pop.
onComplete() => void—When every bubble is popped.
classNamestring—Extra classes for the outer element.

Variants

  • Waiting room. While a long task runs.
  • Reward. `onComplete` unlocks something.

Accessibility

  • Each bubble is a toggle button; the group says how many are left.

Motion and reduced motion

  • Squash keyframes and an expanding ring per pop.
  • Reduced motion: bubbles empty without the squash.