MynduiMyndui
0

Anatomy of Confetti

How a thin canvas-confetti wrapper shares one DEFAULTS object across a button trigger, an imperative ref, and a bare helper — and why origin is normalized to the viewport.

01/04One DEFAULTS object
spread
70° cone — how wide the burst fans
startVelocity
45 — how far chips travel from origin
particleCount
120 pieces per fire(); origin.y defaults to 0.7
tsx
const DEFAULTS: ConfettiOptions = {spread: 70,startVelocity: 45,particleCount: 120,origin: { y: 0.7 },disableForReducedMotion: true,};
01

One DEFAULTS object

Confetti is intentionally thin. Myndui doesn't reimplement particle physics — it wraps canvas-confetti with shared defaults, three call sites, and one accessibility flag that the library already understands.

Every fire path merges the same base options. Spread, velocity, count, and a default origin height are tuned once; call sites only override what they need.

origin.y: 0.7 parks bursts in the lower third of the viewport when no explicit origin is passed — celebration from "below," not raining from the top chrome.

02/04ConfettiButton: rect → viewport fractions
Button rect
e.currentTarget.getBoundingClientRect()
Normalized origin
x = (left + w/2) / innerWidth · y = (top + h/2) / innerHeight
Burst
canvasConfetti({ ...DEFAULTS, origin: { x, y }, ...options })
tsx
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {const rect = e.currentTarget.getBoundingClientRect();const x = (rect.left + rect.width / 2) / window.innerWidth;const y = (rect.top + rect.height / 2) / window.innerHeight;canvasConfetti({ ...DEFAULTS, origin: { x, y }, ...options });onClick?.(e);};
02

ConfettiButton: rect → viewport fractions

The button doesn't fire from a hard-coded point. On click it measures itself, normalizes the center into 0…1 viewport space, and passes that as origin: { x, y } — so the burst reads as coming from the control, wherever it sits after layout, scroll, or resize.

options still win last — you can widen the spread or bump the count without losing the positional origin unless you override it.

03/04Three APIs, one core
ConfettiButton
click → rect origin → fire
Confetti.fire()
imperative ref — toast()-style ergonomics
confetti()
bare helper; all three honor disableForReducedMotion
tsx
React.useImperativeHandle(ref,() => ({  fire: (override?: ConfettiOptions) => {    canvasConfetti({ ...DEFAULTS, ...options, ...override });  },}),[options],);function confetti(options?: ConfettiOptions) {canvasConfetti({ ...DEFAULTS, ...options });}
03

Three APIs, one core

Same merge, three ergonomics:

  1. ConfettiButton — declarative trigger, origin from the click target.
  2. <Confetti ref /> + fire() — imperative, toast-style; returns null (no DOM).
  3. confetti(options?) — bare helper for non-React call sites.
04/04Result
04

The result

Defaults once, origin from layout, three ways to pull the trigger — that's the whole wrapper.

Reduced motion

disableForReducedMotion: true is in DEFAULTS, so every path opts in. canvas-confetti suppresses the burst when the user prefers reduced motion — no Myndui-side useReducedMotion branch required, and no empty animation to maintain.

Motion Score

ConfettiCCPaint-triggering
Ccanvas paintcanvas-confetti burst (~2s)
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →