experimental
Confetti Burst
A trigger button that erupts a tight, origin-anchored confetti burst from its own bounding-box centroid on click, drawn on a short-lived full-viewport canvas overlay that self-removes when particles settle. Two-wave timing (a sharp high-velocity snap-out followed by a softer, wider trailing wave) reads like a champagne-cork pop rather than a generic shower. canvas-confetti is dynamically imported on first interaction, so it adds zero parse-time bundle weight. Particle colors resolve from theme tokens at burst time, staying in sync with light/dark. Exposes an imperative ref handle (ConfettiBurstHandle = { fire: () => void }) via forwardRef so a sibling component (e.g. a CopyButton.onCopy) can trigger the same burst without clicking the button itself; the handle exposes fire() only, not the DOM node. Fully accessible: native button, keyboard-operable (Enter/Space fire an identical burst), focus-visible ring, aria-hidden decorative canvas with pointer-events:none.
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/confetti-burstUsage
<ConfettiBurst />Props
Standard props — see source.
Source
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useReducedMotion } from "motion/react";
import styles from "./confetti-burst.module.css";
/**
* canvas-confetti is dynamically imported on the FIRST burst and cached at module
* scope, so it costs nothing at parse time and never weighs down the initial
* bundle. The type is intentionally loose to avoid an import-time type dep.
*/
type ConfettiFn = (opts: Record<string, unknown>) => Promise<unknown> | null;
let confettiRef: ConfettiFn | null = null;
let confettiLoading: Promise<ConfettiFn> | null = null;
function loadConfetti(): Promise<ConfettiFn> {
if (confettiRef) return Promise.resolve(confettiRef);
if (!confettiLoading) {
confettiLoading = import("canvas-confetti").then((mod) => {
confettiRef = mod.default as unknown as ConfettiFn;
return confettiRef;
});
}
return confettiLoading;
}
/** Resolve a CSS custom property to a concrete color at burst time (theme-aware,
* stays in sync with light/dark + custom themes). Runs only on click — never at
* first paint — so it is hydration-safe. Returns "" if the token is unset; the
* caller filters those out — no hardcoded color fallback (token-only contract). */
function resolveVar(root: HTMLElement, name: string): string {
return getComputedStyle(root).getPropertyValue(name).trim();
}
const MAX_PARTICLES = 120;
const VARIANT_CLASSES: Record<NonNullable<ConfettiBurstProps["variant"]>, string> = {
default: "bg-primary text-primary-foreground",
ghost: "bg-muted text-foreground",
outline: "bg-background text-foreground border border-border",
};
/** Imperative handle exposed via `ref`: lets a SIBLING component (e.g. a
* CopyButton.onCopy) trigger the burst without the user clicking the button
* itself. Exposes only `fire()`, not the DOM node — the rendered button keeps
* its own internal ref for origin geometry. */
export type ConfettiBurstHandle = { fire: () => void };
export type ConfettiBurstProps = {
variant?: "default" | "ghost" | "outline";
particleCount?: number;
spread?: number;
colors?: string[];
scalar?: number;
onBurst?: () => void;
disabled?: boolean;
className?: string;
children?: React.ReactNode;
} & Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"color" | "children" | "className"
>;
export const ConfettiBurst = forwardRef<ConfettiBurstHandle, ConfettiBurstProps>(
function ConfettiBurst(
{
variant = "default",
particleCount = 72,
spread = 60,
colors,
scalar = 0.9,
onBurst,
disabled = false,
className = "",
children,
...props
},
ref,
) {
const buttonRef = useRef<HTMLButtonElement>(null);
const flashRef = useRef<HTMLSpanElement>(null);
// Mount-gate so SSR + first client render are byte-identical (static button,
// no flash armed). useReducedMotion() returns null on the server and a concrete
// boolean on the client's first render — null is treated as REDUCED.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !reduce; // canvas burst allowed only when true
const fire = useCallback(() => {
if (disabled) return;
// onBurst fires synchronously, before any canvas work — useful for analytics
// or chained state regardless of motion preference.
onBurst?.();
const button = buttonRef.current;
if (!button) return;
if (!animate) {
// Reduced-motion (or pre-hydration) path: single accent-flash pulse, no
// particles. Re-arm by toggling the data attribute across a reflow.
const flash = flashRef.current;
if (flash) {
flash.removeAttribute("data-flash");
// Force reflow so the animation can restart on rapid re-clicks.
void flash.offsetWidth;
flash.setAttribute("data-flash", "true");
}
return;
}
const rect = button.getBoundingClientRect();
// Origin anchored to the button centroid (its exact bounding-box center),
// normalized to the viewport — the burst feels owned by the element.
const origin = {
x: (rect.left + rect.width / 2) / window.innerWidth,
y: (rect.top + rect.height / 2) / window.innerHeight,
};
const root = document.documentElement;
const palette =
colors && colors.length > 0
? colors
: [
resolveVar(root, "--primary"),
resolveVar(root, "--accent"),
resolveVar(root, "--foreground"),
resolveVar(root, "--muted"),
].filter(Boolean);
const total = Math.min(Math.max(0, Math.round(particleCount)), MAX_PARTICLES);
// Split the burst into a sharp lead wave (~62%) and a softer trailing wave.
const leadCount = Math.round(total * 0.62);
const trailCount = total - leadCount;
loadConfetti().then((confetti) => {
// Wave 1: tight, high-velocity snap-out — the champagne-cork pop.
confetti({
particleCount: leadCount,
angle: 90,
spread: Math.min(spread - 10, 50),
startVelocity: 55,
gravity: 0.8, // slightly floatier than default 1.0 — hangs to feel celebratory
drift: 0.05,
decay: 0.92,
scalar,
ticks: 200,
shapes: ["circle", "circle", "square"], // ~2:1 circle:square
colors: palette,
origin,
disableForReducedMotion: true,
});
// Wave 2: 80ms later, softer + wider — adds depth without extra libraries.
window.setTimeout(() => {
confetti({
particleCount: trailCount,
angle: 90,
spread: spread + 20,
startVelocity: 25,
gravity: 0.8,
drift: 0.05,
decay: 0.9,
scalar: scalar * 0.95,
ticks: 220,
shapes: ["circle", "circle", "square"],
colors: palette,
origin,
disableForReducedMotion: true,
});
}, 80);
});
}, [animate, colors, disabled, onBurst, particleCount, scalar, spread]);
// Expose only fire() to a parent ref — NOT the DOM node. The button keeps its
// own buttonRef for origin geometry; the handle is purely additive.
useImperativeHandle(ref, () => ({ fire }), [fire]);
return (
<button
{...props}
ref={buttonRef}
type="button"
disabled={disabled}
onClick={fire}
className={[
"relative inline-flex items-center justify-center overflow-hidden",
"px-6 py-3 text-sm font-medium",
"rounded-[var(--radius)]",
"cursor-pointer select-none",
VARIANT_CLASSES[variant],
// Hover/active scale + transition are CSS-only and suppressed under
// prefers-reduced-motion via motion-safe:. Timings use tokens, not literals.
"transition-transform duration-[var(--duration)] ease-[var(--ease)]",
"motion-safe:hover:scale-[1.02] motion-safe:active:scale-[0.96]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"ring-offset-background",
"disabled:opacity-50 disabled:pointer-events-none",
className,
]
.filter(Boolean)
.join(" ")}
>
{/* Reduced-motion accent flash overlay; inert (no animation) unless armed
under a reduce media query. aria-hidden — purely decorative. */}
<span ref={flashRef} aria-hidden="true" className={styles.flashLayer} />
<span className="relative">{children}</span>
</button>
);
},
);
ConfettiBurst.displayName = "ConfettiBurst";
Dependencies
- canvas-confetti
- @bottega/tokens