bottega

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):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/confetti-burst

Usage

Usagetsx
<ConfettiBurst />

Props

Standard props — see source.


Source

confetti-burst.tsxtsx
"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