Buttons
Confetti Button
A burst that escapes the button, fanned upward with gravity folded into the arc.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/confetti-button.json'use client';
const css = `
.confetti {
position: relative;
/* Deliberately NOT overflow: hidden — the pieces have to escape the button.
That is also why they are appended to the body rather than to it. */
padding: 13px 28px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: #f3f0e9;
font: 600 14.5px/1 ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
transition: transform 140ms cubic-bezier(0.34, 1.5, 0.5, 1), border-color 180ms ease;
}
.confetti:hover { border-color: rgba(255, 202, 122, 0.5); }
.confetti:active { transform: scale(0.95); }
.confetti:focus-visible { outline: 2px solid #ffca7a; outline-offset: 3px; }
.confetti-piece {
position: fixed;
width: 7px;
height: 11px;
border-radius: 1px;
pointer-events: none;
z-index: 9;
/* --dx/--dy/--spin are written per piece, so one keyframe serves them all
and each still flies its own arc. */
animation: confetti-fly var(--life, 900ms) cubic-bezier(0.18, 0.7, 0.4, 1) forwards;
}
@keyframes confetti-fly {
from { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; }
/* The vertical term has gravity folded in already, so a piece rises and
then falls rather than travelling in a straight line. */
to { transform: translate(var(--dx), var(--dy)) rotate(var(--spin)) scale(0.7); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.confetti { transition: none; }
}
`;
const COLOURS = ['#ffca7a', '#ff7a9c', '#7ae7ff', '#9d7aff', '#7ad9a1'];
const COUNT = 26;
export function ConfettiButton({
children = 'Celebrate',
onClick,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}) {
// Pieces are appended to the body directly rather than held in state: they
// are transient decoration with no bearing on what this renders, and 26 of
// them in state would re-render the button on every frame for nothing.
function burst(event: React.MouseEvent<HTMLButtonElement>) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const r = event.currentTarget.getBoundingClientRect();
const originX = r.left + r.width / 2;
const originY = r.top + r.height / 2;
for (let i = 0; i < COUNT; i++) {
const piece = document.createElement('span');
piece.className = 'confetti-piece';
// A fan pointing upward rather than a full circle: confetti falling
// straight out of the button reads as a leak, not a burst.
const angle = (-140 + Math.random() * 100) * (Math.PI / 180);
const power = 70 + Math.random() * 110;
const dx = Math.cos(angle) * power;
const dy = Math.sin(angle) * power + 90; // + gravity
piece.style.left = `${originX}px`;
piece.style.top = `${originY}px`;
piece.style.background = COLOURS[i % COLOURS.length] ?? '#ffca7a';
piece.style.setProperty('--dx', `${dx}px`);
piece.style.setProperty('--dy', `${dy}px`);
piece.style.setProperty('--spin', `${Math.random() * 720 - 360}deg`);
piece.style.setProperty('--life', `${700 + Math.random() * 500}ms`);
// Removed on animationend, not a timer: lives vary per piece, so a
// fixed timeout would cut some short and leak the rest.
piece.addEventListener('animationend', () => piece.remove());
document.body.appendChild(piece);
}
}
return (
<>
<style>{css}</style>
<button
className="confetti"
type="button"
onClick={e => {
burst(e);
onClick?.(e);
}}
>
{children}
</button>
</>
);
}