How to make a confetti burst on click

A no-library tutorial for a small burst of colorful confetti that pops out from the exact spot where someone clicks a link or button.

Colorful confetti bursting out around a Click me link with a mouse pointer on it

A little confetti on click is a fun way to say thanks, mark a finished task or celebrate a signup. In this tutorial you'll build a small burst that pops out from the exact spot someone clicks, then drifts down and fades away. It uses plain HTML, a few lines of CSS and the Web Animations API, with no library. You'll also make sure links still go where they should, keyboard users get the burst too, and people who prefer less motion don't get it at all.

The idea

The whole effect is simple. When a click happens, you grab its position, create a few dozen tiny colored rectangles right there, and send each one off in a random direction. Every piece spins a bit, falls a little, fades out and then gets removed from the page.

You could draw the pieces on a canvas, but plain span elements are easier to follow and easy to style. Each piece is fixed to the viewport, so it lines up with the click coordinates no matter how far down the page you've scrolled. pointer-events: none means the pieces never block a click.

HTML
<a href="/thanks" class="confetti-link">Say thanks</a>
<button type="button" class="confetti-button">Celebrate</button>

<style>
  .confetti-piece {
    position: fixed;
    left: 0;
    top: 0;
    width: 8px;
    height: 5px;
    border-radius: 1px;
    pointer-events: none;
    z-index: 1000;
  }
</style>

Making and scattering the pieces

The burst function takes an x and y position and loops PIECE_COUNT times. For each piece it picks a random color, a random angle all the way around the circle, and a random distance. A bit of trigonometry turns the angle and distance into a sideways and up or down offset.

element.animate() does the moving. The first keyframe puts the piece on the click point. By 60% of the way through it has flown out and spun. The last keyframe drops it another 60px and fades it to nothing, which reads as a gentle fall. Random durations keep the pieces from landing all at once.

TSX
const CONFETTI_COLORS = ['#ff5fa2', '#9b5de5', '#3a86ff', '#ffd23f', '#2ec4b6'];
const PIECE_COUNT = 40;

function burst(x, y) {
  for (let i = 0; i < PIECE_COUNT; i++) {
    const piece = document.createElement('span');
    piece.className = 'confetti-piece';
    piece.style.background =
      CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
    document.body.appendChild(piece);

    const angle = Math.random() * Math.PI * 2;
    const distance = 40 + Math.random() * 80;
    const dx = Math.cos(angle) * distance;
    const dy = Math.sin(angle) * distance;
    const spin = (Math.random() - 0.5) * 720;

    const animation = piece.animate(
      [
        { transform: `translate(${x}px, ${y}px) rotate(0deg)`, opacity: 1 },
        {
          transform: `translate(${x + dx}px, ${y + dy}px) rotate(${spin}deg)`,
          opacity: 1,
          offset: 0.6,
        },
        {
          transform: `translate(${x + dx}px, ${y + dy + 60}px) rotate(${spin * 1.5}deg)`,
          opacity: 0,
        },
      ],
      {
        duration: 700 + Math.random() * 300,
        easing: 'ease-out',
        fill: 'forwards',
      }
    );

    animation.onfinish = () => piece.remove();
  }
}

Cleaning up when the pieces land

Forty spans per click adds up fast if nobody tidies. That's what the onfinish line in the code above is for: when a piece's animation ends, it removes itself from the page.

fill: 'forwards' keeps each piece at its last keyframe, fully transparent, until it's removed. Without it, a piece would snap back to the top left corner for a split second before the cleanup runs.

Working out where the click came from

Mouse and touch clicks come with clientX and clientY, which are exactly the viewport coordinates the fixed pieces need. Keyboard clicks are different. Pressing Enter on a link or button still fires a click event, but there's no pointer, so those coordinates are no help.

The easy tell is event.detail. For a real click it's the click count, 1 or more. For a click triggered from the keyboard it's 0. In that case, use the center of the element instead, so the burst comes from the button itself.

TSX
function getOrigin(event) {
  // A click from the keyboard (Enter on a link, Enter or Space on a button) has detail 0
  // and no useful pointer position, so use the element's center.
  if (event.detail === 0) {
    const rect = event.currentTarget.getBoundingClientRect();
    return {
      x: rect.left + rect.width / 2,
      y: rect.top + rect.height / 2,
    };
  }
  return { x: event.clientX, y: event.clientY };
}

Skipping the burst for reduced motion

Some people turn on a reduced motion setting in their system because moving things make them feel unwell. Flying confetti is exactly the kind of extra motion they asked to skip, so check the prefers-reduced-motion media query first and return early if it matches.

Buttons that do something on the same page are the simplest case. There's no navigation to worry about, so the handler checks the setting, finds the origin and fires the burst.

TSX
function prefersReducedMotion() {
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

document.querySelectorAll('.confetti-button').forEach((button) => {
  button.addEventListener('click', (event) => {
    if (prefersReducedMotion()) return;
    const { x, y } = getOrigin(event);
    burst(x, y);
  });
});

Letting the link still go somewhere

Links are trickier. A normal click loads the next page right away, and the confetti vanishes with the old one before anyone sees it. The fix is to cancel the default navigation, wait about 300ms, then go to the link's href yourself.

Only do that when the page is actually leaving. If the link opens in a new tab, or someone holds Cmd, Ctrl or Shift to open it elsewhere, let the browser handle it normally. And with reduced motion on, there's no burst, so there's no reason to make anyone wait.

CSS
document.querySelectorAll('.confetti-link').forEach((link) => {
  link.addEventListener('click', (event) => {
    if (prefersReducedMotion()) return;

    const { x, y } = getOrigin(event);
    burst(x, y);

    // New tab or modified click: the page stays open, so no need to wait.
    const opensElsewhere =
      link.target === '_blank' ||
      event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
    if (opensElsewhere) return;

    // Same tab: hold the navigation just long enough to see the pop.
    event.preventDefault();
    setTimeout(() => {
      window.location.href = link.href;
    }, 300);
  });
});

Want a bigger or smaller party? PIECE_COUNT and the distance range are the two numbers to play with. Links that open in a new tab already skip the delay, since the current page stays put and every piece gets to finish its fall.