Originals
FreeBubble 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.
npx @galactrex/ui add bubble-wrapUsage
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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
columns, rows | number | 8, 4 | Bubbles across and down. |
size | number | 34 | Bubble diameter in px. |
onPop | (popped: number, total: number) => void | — | After each pop. |
onComplete | () => void | — | When every bubble is popped. |
className | string | — | 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.