# Confetti Burst

> Canvas confetti fired from a button click with physics, cleanup and reduced-motion support.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/confetti-burst
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/confetti-burst.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/confetti-burst-demo.tsx"
"use client"

import { PartyPopper } from "lucide-react"

import { ConfettiButton } from "@/components/confetti-burst"

export default function ConfettiBurstDemo() {
  return (
    <div className="flex w-full flex-col items-center gap-4 py-8 text-center">
      <p className="text-sm text-muted-foreground">Ship something worth celebrating.</p>
      <ConfettiButton>
        <PartyPopper className="size-4" /> Celebrate
      </ConfettiButton>
      <ConfettiButton
        className="bg-background text-foreground border hover:bg-accent"
        confetti={{ count: 140, spread: 360, velocity: 11, colors: ["#f97316", "#facc15", "#ffffff"] }}
      >
        Big bang
      </ConfettiButton>
    </div>
  )
}
```

## Source

```tsx title="components/confetti-burst.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

const DEFAULT_COLORS = ["#f97316", "#ec4899", "#8b5cf6", "#3b82f6", "#10b981", "#facc15"]

interface Piece {
  x: number
  y: number
  vx: number
  vy: number
  size: number
  color: string
  rotation: number
  spin: number
  tilt: number
  tiltSpeed: number
  life: number
  round: boolean
}

export interface ConfettiOptions {
  /** Number of pieces */
  count?: number
  /** Launch angle in degrees, 90 is straight up */
  angle?: number
  /** Spread of the cone in degrees */
  spread?: number
  /** Initial speed in px per frame */
  velocity?: number
  colors?: string[]
  /** Origin as a fraction of the canvas, 0 to 1 */
  origin?: { x: number; y: number }
}

export interface ConfettiHandle {
  fire: (options?: ConfettiOptions) => void
}

export interface ConfettiCanvasProps extends Omit<React.ComponentProps<"canvas">, "ref"> {
  ref?: React.Ref<ConfettiHandle>
}

// A full-size, click-through canvas. Call `ref.current.fire()` to launch a burst.
// All physics run in requestAnimationFrame and stop as soon as no piece is alive.
export function ConfettiCanvas({ ref, className, ...props }: ConfettiCanvasProps) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null)
  const piecesRef = React.useRef<Piece[]>([])
  const rafRef = React.useRef(0)

  const loop = React.useCallback(function tick() {
    const canvas = canvasRef.current
    const ctx = canvas?.getContext("2d")
    if (!canvas || !ctx) return
    const dpr = window.devicePixelRatio || 1
    const w = canvas.clientWidth
    const h = canvas.clientHeight
    if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
      canvas.width = Math.round(w * dpr)
      canvas.height = Math.round(h * dpr)
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
    ctx.clearRect(0, 0, w, h)

    const alive: Piece[] = []
    for (const p of piecesRef.current) {
      p.vx *= 0.99
      p.vy = p.vy * 0.99 + 0.28
      p.x += p.vx
      p.y += p.vy
      p.rotation += p.spin
      p.tilt += p.tiltSpeed
      p.life -= 0.008
      if (p.life <= 0 || p.y > h + 20) continue
      alive.push(p)
      ctx.save()
      ctx.globalAlpha = Math.min(1, p.life * 2.5)
      ctx.translate(p.x, p.y)
      ctx.rotate(p.rotation)
      ctx.scale(1, Math.cos(p.tilt))
      ctx.fillStyle = p.color
      if (p.round) {
        ctx.beginPath()
        ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2)
        ctx.fill()
      } else {
        ctx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2)
      }
      ctx.restore()
    }
    piecesRef.current = alive
    if (alive.length > 0) rafRef.current = requestAnimationFrame(tick)
    else ctx.clearRect(0, 0, w, h)
  }, [])

  React.useImperativeHandle(
    ref,
    () => ({
      fire(options = {}) {
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
        const canvas = canvasRef.current
        if (!canvas) return
        const {
          count = 90,
          angle = 90,
          spread = 70,
          velocity = 13,
          colors = DEFAULT_COLORS,
          origin = { x: 0.5, y: 0.6 },
        } = options
        const ox = canvas.clientWidth * origin.x
        const oy = canvas.clientHeight * origin.y
        for (let i = 0; i < count; i++) {
          const a = ((angle + (Math.random() - 0.5) * spread) * Math.PI) / 180
          const v = velocity * (0.45 + Math.random() * 0.75)
          piecesRef.current.push({
            x: ox,
            y: oy,
            vx: Math.cos(a) * v,
            vy: -Math.sin(a) * v,
            size: 6 + Math.random() * 6,
            color: colors[Math.floor(Math.random() * colors.length)],
            rotation: Math.random() * Math.PI * 2,
            spin: (Math.random() - 0.5) * 0.3,
            tilt: Math.random() * Math.PI * 2,
            tiltSpeed: 0.1 + Math.random() * 0.2,
            life: 0.9 + Math.random() * 0.5,
            round: Math.random() < 0.25,
          })
        }
        cancelAnimationFrame(rafRef.current)
        rafRef.current = requestAnimationFrame(loop)
      },
    }),
    [loop]
  )

  React.useEffect(() => () => cancelAnimationFrame(rafRef.current), [])

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      className={cn("pointer-events-none absolute inset-0 size-full", className)}
      {...props}
    />
  )
}

export interface ConfettiButtonProps extends React.ComponentProps<"button"> {
  /** Options forwarded to every burst */
  confetti?: ConfettiOptions
}

// A button that fires a burst from its own position. The canvas is fixed to the
// viewport, so pieces can fly anywhere without affecting page layout.
export function ConfettiButton({ confetti, className, children, onClick, type = "button", ...props }: ConfettiButtonProps) {
  const canvasRef = React.useRef<ConfettiHandle>(null)

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    onClick?.(e)
    const btn = e.currentTarget.getBoundingClientRect()
    canvasRef.current?.fire({
      origin: {
        x: (btn.left + btn.width / 2) / window.innerWidth,
        y: (btn.top + btn.height / 2) / window.innerHeight,
      },
      ...confetti,
    })
  }

  return (
    <span className="relative inline-flex">
      <button
        type={type}
        onClick={handleClick}
        className={cn(
          "relative z-10 inline-flex h-11 items-center justify-center gap-2 rounded-xl bg-primary px-6 text-sm font-medium text-primary-foreground shadow-sm transition-[transform,opacity] outline-none hover:opacity-90 focus-visible:ring-4 focus-visible:ring-ring/40 active:scale-95 motion-reduce:transition-none",
          className
        )}
        {...props}
      >
        {children}
      </button>
      <ConfettiCanvas ref={canvasRef} className="fixed z-50" />
    </span>
  )
}
```
