Confetti Burst

Clicking fires a cone of paper confetti that tumbles, slows with air drag and falls out of view.

  • Canvas
  • Backgrounds
  • Click
  • Added

AI prompt

Create a dependency-free confetti burst in React with a <canvas> overlay. On click, spawn 90 pieces at the button centre, each with a random angle inside a 70-degree cone pointing up, speed 6–12 px/frame, a size of 6×10px, a random rotation speed, and a colour from a palette of 4 brand colours. Each requestAnimationFrame: apply gravity (+0.25 to vy), air drag (multiply vx and vy by 0.985), advance rotation, and draw each piece as a rotated rectangle whose width is scaled by Math.cos(tilt) so it looks like it flips. Remove pieces that leave the canvas and stop the loop when none are left. Size the canvas with devicePixelRatio and set pointer-events: none on it. Under prefers-reduced-motion, skip the confetti and flash the button once with a 150ms opacity change instead. Button label: "Celebrate".

Source

Demo.tsx
'use client'

import { useEffect, useRef } from 'react'
import s from './demo.module.css'

type Piece = { x: number; y: number; vx: number; vy: number; rot: number; vr: number; tilt: number; color: string }

export default function ConfettiBurst({ labels }: { labels: Record<string, string> }) {
  const canvas = useRef<HTMLCanvasElement>(null)
  const button = useRef<HTMLButtonElement>(null)
  const pieces = useRef<Piece[]>([])
  const raf = useRef(0)

  useEffect(() => () => cancelAnimationFrame(raf.current), [])

  const fire = () => {
    const c = canvas.current!
    const b = button.current!
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      b.animate([{ opacity: 0.4 }, { opacity: 1 }], 150)
      return
    }
    const ctx = c.getContext('2d')!
    const dpr = window.devicePixelRatio || 1
    const w = c.clientWidth
    const h = c.clientHeight
    c.width = w * dpr
    c.height = h * dpr
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)

    const css = getComputedStyle(c)
    const palette = ['--c1', '--c2', '--c3', '--c4'].map((v) => css.getPropertyValue(v).trim())
    const cr = c.getBoundingClientRect()
    const br = b.getBoundingClientRect()
    const ox = br.left + br.width / 2 - cr.left
    const oy = br.top + br.height / 2 - cr.top

    for (let i = 0; i < 90; i++) {
      const angle = -Math.PI / 2 + (Math.random() - 0.5) * ((70 * Math.PI) / 180)
      const speed = 6 + Math.random() * 6
      pieces.current.push({
        x: ox,
        y: oy,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        rot: Math.random() * Math.PI,
        vr: (Math.random() - 0.5) * 0.3,
        tilt: Math.random() * Math.PI,
        color: palette[i % palette.length],
      })
    }

    cancelAnimationFrame(raf.current)
    const tick = () => {
      ctx.clearRect(0, 0, w, h)
      pieces.current = pieces.current.filter((p) => p.y < h + 20 && p.x > -20 && p.x < w + 20)
      for (const p of pieces.current) {
        p.vy += 0.25
        p.vx *= 0.985
        p.vy *= 0.985
        p.x += p.vx
        p.y += p.vy
        p.rot += p.vr
        p.tilt += 0.12
        ctx.save()
        ctx.translate(p.x, p.y)
        ctx.rotate(p.rot)
        ctx.fillStyle = p.color
        ctx.fillRect(-3 * Math.cos(p.tilt), -5, 6 * Math.cos(p.tilt), 10)
        ctx.restore()
      }
      if (pieces.current.length) raf.current = requestAnimationFrame(tick)
    }
    raf.current = requestAnimationFrame(tick)
  }

  return (
    <div className={s.wrap}>
      <canvas ref={canvas} className={s.canvas} aria-hidden="true" />
      <button ref={button} type="button" className={s.button} onClick={fire}>
        {labels.cta}
      </button>
    </div>
  )
}
demo.module.css
.wrap {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: end center;
  padding-bottom: 36px;
}

.canvas {
  --c1: var(--stage-accent);
  --c2: var(--stage-ink);
  --c3: oklch(78% 0.12 85);
  --c4: oklch(62% 0.1 160);
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.button {
  position: relative;
  height: 48px;
  padding: 0 24px;
  border: 0;
  border-radius: 12px;
  background: var(--stage-ink);
  color: var(--stage-bg);
  font: 600 var(--text-base) / 1 var(--font-body);
  white-space: nowrap;
  cursor: pointer;
}

.button:hover { background: var(--stage-accent); color: var(--stage-accent-ink); }
.button:active { transform: translateY(1px); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.