Skip to content
Plinth

Buttons

Confetti Button

A burst that escapes the button, fanned upward with gravity folded into the arc.

#button#confetti#celebration#particles#animation

Install

npx shadcn@latest add https://plinthui.com/r/confetti-button.json
Open in v0
'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>
    </>
  );
}

More in Buttons

Similar tags