Confetti Button
A button that throws a handful of paper into the air on press, each piece lobbed on its own arc.
Installation
npx shadcn@latest add @loomui/confetti-buttonUsage
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
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | 18 | Pieces thrown by one press. |
duration | number | 900 | Milliseconds a piece stays in the air. |
spread | number | 90 | Furthest a piece travels sideways, in pixels. |
colors | string[] | loom palette | Colours a piece is picked from. |
disabled | boolean | false | Ignore presses and throw nothing. |
className | string | none | Merged 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.