Feedback

Confetti

A DOM-particle celebration burst layer — bump a counter to fire confetti at any point inside a relative container.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const DEFAULT_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]

const GRAVITY = 1600 // px/s^2, downward acceleration
const DRAG = 1.1 // 1/s, exponential air-drag applied to both velocity axes
const MAX_DT = 1 / 30 // clamp so a stalled/background tab can't catapult particles on resume

interface ConfettiParticle {
  id: number
  x: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/confetti.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Confetti" component. No animation
library or canvas — DOM <span> particles driven by a delta-time normalized
requestAnimationFrame loop.

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: fire: number (increment this, e.g. setFire(n => n + 1), to trigger
  one burst — 0 and the value present on mount never fire, so a controlled
  counter that starts at 0 is safe to wire up directly); particleCount?
  (default 80); origin? ({ x: number; y: number }, fraction of the
  component's own box, default { x: 0.5, y: 0.5 }); colors? (string[],
  default the five chart tokens ["var(--chart-1)" .. "var(--chart-5)"] so a
  burst matches any theme without a single hardcoded hex — this is the
  differentiator over a typical confetti library, which ships fixed hex
  defaults); className.
- Renders one absolute inset-0 pointer-events-none aria-hidden layer with no
  visible box of its own. The consumer places it inside a `relative`
  container that already holds the content being celebrated.

Behavior
- Compare `fire` against its previous value in an effect; only spawn a burst
  when it actually changed and isn't 0 — this makes the trigger a plain
  incrementing counter, not a boolean toggle a consumer has to reset.
- On trigger: measure the container's own bounding box, spawn particleCount
  particles at origin (as a fraction of that box), each with a random
  full-circle direction, a speed magnitude, and a constant upward bias
  subtracted from vertical velocity — so the burst reads as "outward and up"
  from any origin, including one pinned to the bottom edge. Color cycles
  through the colors array in spawn order.
- Physics run in a single rAF loop, integrated with real delta-time each
  frame (not a fixed step) so speed is consistent across refresh rates:
  velocity decays exponentially (air drag), gravity accumulates linearly,
  rotation advances by an angular velocity per particle. Particles fade out
  over the last ~20% of their lifetime.
- A particle is removed once its lifetime elapses or it falls well past the
  container's bottom edge. The loop stops entirely once no particles remain,
  and restarts the next time `fire` changes — it never spins while idle.
- New bursts append to whatever is still animating instead of resetting it,
  so mashing the trigger compounds rather than restarting.
- prefers-reduced-motion (useSyncExternalStore over matchMedia, false server
  snapshot): spawning is a silent no-op. No fallback animation — a consumer
  who wants a visible acknowledgment under reduced motion should pair this
  with something like a toast instead.
- Clean up the animation frame on unmount.

Rendering & styling
- No colors of its own beyond what's passed in `colors` — those are plain
  CSS color strings (token var(...) references by default) assigned directly
  to each particle's inline backgroundColor, so dark mode and rebranding are
  just a props change.
- Particles are small absolutely-positioned rounded rectangles; position and
  rotation are inline transforms updated every frame, not CSS keyframes,
  since each particle's trajectory is unique per burst.
- Root layer is aria-hidden and pointer-events-none so it never intercepts
  clicks or gets announced; cn() merges the consumer's className onto it.

Customization levers
- Physics: GRAVITY / DRAG / initial speed and upward-bias ranges are the
  only knobs — heavier gravity and higher drag reads as "confetti," lower
  drag and speed reads as "floaty sparkle."
- Colors: swap the five chart-token defaults for brand tokens, or pass a
  single-color array for a monochrome burst.
- Origin + particleCount: two origins fired on the same `fire` change (one
  pinned left, one right) reads as a two-cannon celebration without any new
  prop — just render two <Confetti> layers with mirrored origin.x.
- Particle shape: swap the rounded-[1px] rectangle for a rounded-full circle
  or a wider aspect ratio for a "ribbon" look.

Concepts

  • Increment-to-fire triggerfire is a monotonic counter, not a boolean; comparing it against its previous value (not truthiness) lets a controlled consumer just do setFire(n => n + 1) without ever resetting a "should I play" flag.
  • Delta-time normalized simulation — velocity integrates against real elapsed frame time, not a fixed step, so drag and gravity look identical whether the tab renders at 60fps or 144fps.
  • Compounding bursts — a shared particle ref (not per-burst state) means firing again while confetti is still falling adds to it instead of restarting the animation from zero.
  • Token-colored particles — colors default to the five var(--chart-N) tokens instead of hardcoded hex, so the same burst reads correctly in every theme and color scheme with zero prop changes.
  • Reduced-motion opt-out, no fallback — under prefers-reduced-motion, spawning is a silent no-op; the component doesn't invent a substitute animation, leaving that choice (e.g. a toast) to the consumer.

On This Page