Confetti Button

A button that throws a handful of paper into the air on press, each piece lobbed on its own arc.

Loading preview

Installation

npx shadcn@latest add @loomui/confetti-button

Usage

import { ConfettiButton } from "@/components/ui/confetti-button"
<ConfettiButton onClick={() => publish()} className="rounded-md px-5 py-2.5">
  Ship it
</ConfettiButton>

Confetti belongs on the one press in a session that is worth marking. Put it on a save button people hit forty times a day and it stops meaning anything by the fourth.

Each piece is two nested spans, because a lob is two motions running on two different clocks. The outer span drifts sideways at a constant rate. The inner one rises, slows, turns over and falls, and spins the whole way. A single element can only ever travel in a straight line between its two keyframes, and a straight line reads as a spark rather than paper.

The arc comes from animation-timing-function set inside the keyframes rather than on the animation. The rise is eased out so it runs out of momentum at the top, and the fall is eased in so gravity takes it. One timing function across the whole animation gives you a shape that goes up and down at the same rate, which nothing in the world does.

Pieces are thrown in a fan across the top, never downward. A piece that starts by heading for the floor reads as something leaking out of the button. Sizes, colours, angles, spin and the mix of chips and dots are all rolled per piece, so two presses never produce the same burst.

The layer holding the pieces is pinned to the middle of the button and is not clipped by it, so the throw carries past the edge. That layer is zero-sized and pointer-events-none, so it never covers the thing it was thrown from.

Sizing a burst

<ConfettiButton count={34} spread={140} duration={1200}>
  Bigger party
</ConfettiButton>

count past about 40 stops reading as individual pieces and starts reading as a cloud, and it is that many elements animating at once. spread is how far the furthest piece travels sideways, so it wants to be in proportion to the button rather than fixed. duration past about 1400ms leaves paper hanging in the air long after the press it was celebrating.

<ConfettiButton colors={["#f472b6", "#c084fc", "#818cf8"]} count={10}>
  Just a little
</ConfettiButton>

Props

PropTypeDefaultDescription
countnumber18Pieces thrown by one press.
durationnumber900Milliseconds a piece stays in the air.
spreadnumber90Furthest a piece travels sideways, in pixels.
colorsstring[]loom paletteColours a piece is picked from.
disabledbooleanfalseIgnore presses and throw nothing.
classNamestringnoneMerged onto the button. Padding and type here.

Any other <button> prop is forwarded.

Accessibility

The paper is aria-hidden decoration on top of a real <button>, so a screen reader is told about the action and not the celebration. Under prefers-reduced-motion nothing is thrown at all and onClick still fires, because the button's job is the click and the confetti is a reward for it. Every burst clears itself, and pending timers are cancelled on unmount.