Confetti in your theme colors: fireConfetti(), a useConfetti() hook that fires from an element, and a ConfettiButton. Presets for burst, side cannons, stars and fireworks; nothing fires under reduced motion.
acme-web
main · 8f3c2a1
Ready to ship 3 commits to production.
"use client"
import * as React from "react"
import { CheckCircle2, GitBranch, Rocket } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { useConfetti } from "@/components/ballmac/confetti"
type State = "idle" | "deploying" | "done"
export default function ConfettiDemo() {
const [state, setState] = React.useState<State>("idle")
const buttonRef = React.useRef<HTMLButtonElement>(null)
const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
const { fireFrom } = useConfetti()
React.useEffect(() => () => clearTimeout(timer.current), [])
const deploy = () => {
if (state === "done") {
setState("idle")
return
}
setState("deploying")
timer.current = setTimeout(() => {
setState("done")
void fireFrom(buttonRef.current)
}, 1400)
}
return (
<div className="w-full max-w-sm rounded-xl border bg-card p-5 text-card-foreground shadow-xs">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-lg bg-[linear-gradient(135deg,var(--chart-1),var(--chart-4))] text-white">
<Rocket className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">acme-web</p>
<p className="flex items-center gap-1 font-mono text-xs text-muted-foreground">
<GitBranch className="size-3" aria-hidden="true" /> main · 8f3c2a1
</p>
</div>
</div>
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,var(--chart-1),var(--chart-4))] transition-[width] duration-[1400ms] ease-(--bm-ease-out)"
style={{ width: state === "idle" ? "0%" : "100%" }}
/>
</div>
<p className="mt-3 flex min-h-5 items-center gap-1.5 text-sm" aria-live="polite">
{state === "idle" && <span className="text-muted-foreground">Ready to ship 3 commits to production.</span>}
{state === "deploying" && <span className="text-muted-foreground">Building and rolling out…</span>}
{state === "done" && (
<>
<CheckCircle2 className="size-4 text-chart-2" aria-hidden="true" />
<span className="font-medium">Deployed to production in 38s</span>
</>
)}
</p>
<Button ref={buttonRef} className="mt-4 w-full" loading={state === "deploying"} onClick={deploy}>
{state === "done" ? "Deploy again" : "Deploy"}
</Button>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/confettiInstall the dependencies.
$ pnpm add canvas-confetti@^1.9.3 class-variance-authorityAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/colorCopy the source into your project.
components/ballmac/confetti.tsx// Ballmac UI: Confetti. https://ui.ballmac.com/components/confetti "use client" import * as React from "react" import confetti from "canvas-confetti" import { type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ballmac/button" import { cssColorToRgba } from "@/lib/ballmac/color" type ConfettiPreset = "burst" | "sides" | "stars" | "fireworks" type FireConfettiOptions = Omit<confetti.Options, "colors" | "origin"> & { /** Shape of the effect. */ preset?: ConfettiPreset /** Theme variables ("--chart-1") or CSS colors. Defaults to the five chart tokens. */ colors?: string[] /** Where the burst starts, 0–1 of the viewport. Ignored when `element` is set. */ origin?: { x?: number; y?: number } /** Start the burst from the center of this element. */ element?: Element | null } const DEFAULT_COLORS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] const REDUCED_QUERY = "(prefers-reduced-motion: reduce)" function prefersReducedMotion() { return typeof window !== "undefined" && !!window.matchMedia?.(REDUCED_QUERY).matches } /** canvas-confetti only parses hex strings, so theme colors are resolved and converted first. */ function toHex(color: string) { const [r, g, b] = cssColorToRgba(document.documentElement, color) return "#" + [r, g, b].map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("") } function originOf(element: Element) { const rect = element.getBoundingClientRect() return { x: (rect.left + rect.width / 2) / window.innerWidth, y: (rect.top + rect.height / 2) / window.innerHeight, } } /** * Fires confetti in theme colors. Resolves when the particles have settled. * Does nothing (and resolves at once) on the server or when the user prefers reduced motion. */ function fireConfetti({ preset = "burst", colors, origin, element, ...options }: FireConfettiOptions = {}): Promise<void> { if (typeof window === "undefined" || prefersReducedMotion()) return Promise.resolve() const palette = (colors ?? DEFAULT_COLORS).map(toHex) const from = { x: 0.5, y: 0.6, ...(element ? originOf(element) : origin) } const base: confetti.Options = { colors: palette, disableForReducedMotion: true, zIndex: 100, ...options } const shots: confetti.Options[] = [] if (preset === "sides") { shots.push( { ...base, particleCount: 60, angle: 60, spread: 55, startVelocity: 55, origin: { x: 0, y: 0.75 } }, { ...base, particleCount: 60, angle: 120, spread: 55, startVelocity: 55, origin: { x: 1, y: 0.75 } } ) } else if (preset === "stars") { const star = { ...base, shapes: ["star"] as confetti.Shape[], spread: 360, ticks: 70, gravity: 0, decay: 0.94, startVelocity: 22, origin: from } shots.push({ ...star, particleCount: 36, scalar: 1.1 }, { ...star, particleCount: 14, scalar: 0.7, shapes: ["circle"] }) } else if (preset === "fireworks") { for (let i = 0; i < 4; i++) { shots.push({ ...base, particleCount: 50, spread: 360, startVelocity: 28, ticks: 70, origin: { x: 0.2 + Math.random() * 0.6, y: 0.2 + Math.random() * 0.3 }, }) } } else { shots.push( { ...base, particleCount: 70, spread: 70, startVelocity: 38, scalar: 0.9, origin: from }, { ...base, particleCount: 30, spread: 110, startVelocity: 24, scalar: 0.7, decay: 0.92, origin: from } ) } // Fireworks go off one after another; other presets fire together. const stagger = preset === "fireworks" ? 260 : 0 return Promise.all( shots.map( (shot, i) => new Promise<void>((resolve) => { const run = () => Promise.resolve(confetti(shot)).then(() => resolve(), () => resolve()) if (stagger && i) window.setTimeout(run, i * stagger) else run() }) ) ).then(() => undefined) } /** Stable helpers for firing confetti from event handlers. */ function useConfetti() { return React.useMemo( () => ({ /** Fires confetti; see fireConfetti for options. */ fire: fireConfetti, /** Fires confetti from the center of an element, such as the button that was clicked. */ fireFrom: (element: Element | null, options?: Omit<FireConfettiOptions, "element">) => fireConfetti({ ...options, element }), }), [] ) } type ConfettiButtonProps = React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { /** Confetti options; the burst starts from the button unless `origin` is set. */ options?: Omit<FireConfettiOptions, "element"> } function ConfettiButton({ options, variant, size, shape, className, onClick, type = "button", ...props }: ConfettiButtonProps) { return ( <button data-slot="confetti-button" type={type} className={cn(buttonVariants({ variant, size, shape }), className)} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { void fireConfetti({ ...options, element: options?.origin ? undefined : event.currentTarget }) } }} {...props} /> ) } export { ConfettiButton, fireConfetti, useConfetti, type ConfettiButtonProps, type ConfettiPreset, type FireConfettiOptions }Update the import paths to match your project setup.
Usage
import { ConfettiButton, fireConfetti, useConfetti } from "@/components/ballmac/confetti"The full example is in the Code tab above.
Examples
Presets
import { PartyPopper, Sparkles, Star, Zap } from "lucide-react"
import { ConfettiButton } from "@/components/ballmac/confetti"
export default function ConfettiPresets() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<ConfettiButton>
<PartyPopper aria-hidden="true" />
Burst
</ConfettiButton>
<ConfettiButton variant="outline" options={{ preset: "sides" }}>
<Zap aria-hidden="true" />
Side cannons
</ConfettiButton>
<ConfettiButton variant="outline" options={{ preset: "stars", colors: ["--chart-3", "--chart-5"] }}>
<Star aria-hidden="true" />
Stars
</ConfettiButton>
<ConfettiButton variant="outline" options={{ preset: "fireworks" }}>
<Sparkles aria-hidden="true" />
Fireworks
</ConfettiButton>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
optionsConfetti options; the burst starts from the button unless `origin` is set. | Omit<FireConfettiOptions, "element"> | — |
Also accepts the standard attributes of its root element.
Use with AI
Call fireConfetti({ preset, colors, element }) from an event handler, or useConfetti().fireFrom(buttonRef.current) after an async success. <ConfettiButton options={{ preset: "stars" }}> is a Ballmac button that bursts from itself on click. Colors default to --chart-1…5. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Confetti (@ballmac/confetti) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Rewarding a finished milestone: first deploy, upgrade, completed onboarding
- Success states after a long async action
- Playful marketing buttons (sparingly)
Not for
- Routine actions such as saving a form (use a toast or inline check)
- As the only success signal; always show a text confirmation too
Registry JSON: https://ui.ballmac.com/r/confetti.json
Credits
Free to use in personal and commercial projects.
Pairs well with
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Number Ticker
Counts up to a number when it scrolls into view, with locale-aware formatting for currency, percentages and decimals.
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.
Animated Grid
A decorative hairline grid background where a few random cells softly light up and fade, like instrument lights. SVG, hydration-safe, static under reduced motion.