Teseria
All elements

Confetti

A canvas that bursts. Pieces leave fast, drag slows them, gravity takes over, and each one flips about its own axis as it falls so it catches the light.

Code

1. Install

Terminal
npm i lucide-react

2. Files

Confetti.tsx132 lines
"use client";
// Confetti: a canvas that bursts. Pieces leave fast, drag slows them, gravity takes over, and each one flips about its own axis
// as it falls so it catches the light. One rAF loop, frame-rate independent; reduced motion shows a short ring instead.
import { useEffect, useRef, type CSSProperties, type RefObject } from "react";
import { PartyPopper } from "lucide-react";

type Piece = { x: number; y: number; vx: number; vy: number; w: number; h: number; rot: number; vr: number; tilt: number; vt: number; hue: number; life: number };

export type ConfettiCue = "burst";

export type ConfettiProps = {
  /** Pieces per burst. */
  count?: number;
  /** 0..100, how hard it throws. */
  power?: number;
  /** Width of the frame the confetti flies in, px. */
  width?: number;
  /** Height of the frame, px. */
  height?: number;
  /** Button text. */
  label?: string;
  /** Called on every burst. */
  onBurst?: () => void;
  /** Sound hook: "burst" on every burst. */
  onSound?: (cue: ConfettiCue) => void;
  className?: string;
  style?: CSSProperties;
};

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

function useConfetti(canvas: RefObject<HTMLCanvasElement | null>, count: number, power: number) {
  const pieces = useRef<Piece[]>([]);
  const raf = useRef(0);
  const loop = useRef<(t: number) => void>(() => {});

  useEffect(() => {
    let last = 0;
    loop.current = (t: number) => {
      const c = canvas.current;
      const ctx = c?.getContext("2d");
      if (!c || !ctx) return;
      const dpr = window.devicePixelRatio || 1;
      const width = c.width / dpr;
      const height = c.height / dpr;
      const dt = last ? clamp((t - last) / 16.67, 0, 2.5) : 1;
      last = t;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, width, height);
      const live: Piece[] = [];
      for (const p of pieces.current) {
        p.vx *= 0.96 ** dt;
        p.vy = p.vy * 0.96 ** dt + 0.22 * dt;
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.rot += p.vr * dt;
        p.tilt += p.vt * dt;
        p.life -= dt;
        if (p.y > height + 20 || p.life <= 0) continue;
        live.push(p);
        const flip = Math.cos(p.tilt);
        ctx.save();
        ctx.translate(p.x, p.y);
        ctx.rotate(p.rot);
        ctx.scale(1, Math.max(0.12, Math.abs(flip)));
        ctx.globalAlpha = clamp(p.life / 30, 0, 1);
        ctx.fillStyle = `oklch(${flip > 0 ? 0.78 : 0.62} 0.17 ${p.hue})`;
        ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
        ctx.restore();
      }
      pieces.current = live;
      if (live.length) raf.current = requestAnimationFrame(loop.current);
      else {
        raf.current = 0;
        last = 0;
      }
    };
    return () => cancelAnimationFrame(raf.current);
  }, [canvas]);

  return (x: number, y: number) => {
    const c = canvas.current;
    if (!c) return;
    const dpr = window.devicePixelRatio || 1;
    const b = c.getBoundingClientRect();
    if (c.width !== b.width * dpr) {
      c.width = b.width * dpr;
      c.height = b.height * dpr;
    }
    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
      c.animate([{ boxShadow: "inset 0 0 0 0 oklch(0.7 0.17 60)" }, { boxShadow: "inset 0 0 0 12px oklch(0.7 0.17 60)", offset: 0.3 }, { boxShadow: "inset 0 0 0 0 transparent" }], { duration: 420 });
      return;
    }
    const v = 6 + (power / 100) * 10;
    const base = (Math.random() * 360) | 0;
    for (let i = 0; i < count; i++) {
      const a = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
      const s = v * (0.45 + Math.random() * 0.8);
      pieces.current.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, w: 5 + Math.random() * 5, h: 7 + Math.random() * 6, rot: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.3, tilt: Math.random() * Math.PI, vt: 0.12 + Math.random() * 0.22, hue: (base + [0, 40, 180, 220, 300][i % 5] + Math.random() * 20) % 360, life: 150 + Math.random() * 60 });
    }
    if (!raf.current) raf.current = requestAnimationFrame(loop.current);
  };
}

export function Confetti({ count = 110, power = 55, width = 300, height = 220, label = "Celebrate", onBurst, onSound, className, style }: ConfettiProps) {
  const canvas = useRef<HTMLCanvasElement>(null);
  const btn = useRef<HTMLButtonElement>(null);
  const burst = useConfetti(canvas, count, power);
  return (
    <div className={`cnfx-root${className ? " " + className : ""}`} style={{ width, height, ...style }}>
      <canvas ref={canvas} className="cnfx-canvas" aria-hidden />
      <button
        ref={btn}
        type="button"
        className="cnfx-btn"
        onClick={() => {
          const bt = btn.current;
          const cv = canvas.current;
          if (!bt || !cv) return;
          onSound?.("burst");
          onBurst?.();
          const from = bt.getBoundingClientRect();
          const stage = cv.getBoundingClientRect();
          burst(from.left + from.width / 2 - stage.left, from.top + from.height / 2 - stage.top);
          bt.animate([{ scale: 1 }, { scale: 0.92, offset: 0.2 }, { scale: 1.06, offset: 0.55 }, { scale: 1 }], { duration: 420, easing: "cubic-bezier(.22,1,.36,1)" });
        }}
      >
        <PartyPopper size={16} strokeWidth={2.2} /> {label}
      </button>
    </div>
  );
}
Confetti.css55 lines
.cnfx-root,
.cnfx-root *,
.cnfx-root *::before,
.cnfx-root *::after {
  box-sizing: border-box;
}
.cnfx-root {
  font-family: var(--ts-font, system-ui, sans-serif);
}
:where(.cnfx-root) button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
}
:where(.cnfx-root) :focus-visible {
  outline: 2px solid var(--ts-focus, #3446eb);
  outline-offset: 2px;
}
/* stage: the canvas fills it, the button sits in the middle above it */
.cnfx-root {
  position: relative;
  display: grid;
  place-items: center;
  color: var(--ts-fg);
}
.cnfx-canvas {
  pointer-events: none;
  border-radius: 20px;
  height: 100%;
  width: 100%;
  position: absolute;
  inset: 0;
}
.cnfx-btn {
  position: relative;
  display: inline-flex;
  gap: 8px;
  align-items: center;
  padding-inline: 20px;
  height: 44px;
  color: var(--ts-raised);
  background: var(--ts-fg);
  border: none;
  border-radius: 999px;
  font-weight: 500;
  font-size: 14px;
  cursor: pointer;
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.cnfx-btn:active {
  scale: 0.96;
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { Confetti } from "./Confetti";
import "./Confetti.css";

export default function Demo() {
  return <Confetti />;
}