Button That Bursts Particles on Press

A celebration card with a button that throws particles for as long as you hold it. Coloured dots and chips shoot up from under your pointer, spin, slow, arc over and fall off the bottom of the section. Two smaller buttons use other palettes: blue bubbles and orange embers. The main button fires a short burst by itself every second or so until you touch the page. The card is centred and the main button goes full width on phones.

Code

// Adapted from Magic UI (https://magicui.design/docs/components/cool-mode)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useCallback, useEffect, useRef, type PointerEvent, type ReactNode, type RefObject } from "react";
import { PartyPopper } from "lucide-react";

const palettes = {
  party: ["#f43f5e", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6", "#ec4899"],
  bubbles: ["#7dd3fc", "#38bdf8", "#bae6fd", "#0ea5e9"],
  embers: ["#fbbf24", "#f97316", "#ef4444", "#fde047"],
};

const SIZES = [8, 12, 16, 22, 28];
const LIMIT = 45;

type Particle = { el: HTMLSpanElement; x: number; y: number; vx: number; vy: number; rot: number; spin: number; size: number };

type ParticleButtonProps = {
  /** The box the particles fly around in. It needs `relative` and `overflow-hidden`. */
  layerRef: RefObject<HTMLElement | null>;
  colors: string[];
  shape?: "circle" | "square" | "mix";
  className?: string;
  children: ReactNode;
  /** Seconds between automatic bursts while nobody has touched the page. 0 turns it off. */
  autoEvery?: number;
  idle?: RefObject<boolean>;
};

/**
 * While the button is held, shapes shoot out from under the pointer, spin,
 * arc over and fall out of the layer. A keyboard press fires a short burst
 * from the middle of the button.
 */
function ParticleButton({ layerRef, colors, shape = "circle", className = "", children, autoEvery = 0, idle }: ParticleButtonProps) {
  const buttonRef = useRef<HTMLButtonElement>(null);
  const state = useRef({ particles: [] as Particle[], holding: false, until: 0, x: 0, y: 0, frame: 0, lastSpawn: 0, lastTime: 0 });

  const loop = useCallback(
    (time: number) => {
      const s = state.current;
      const layer = layerRef.current;
      if (!layer) return;
      // Speeds are tuned per 60fps frame, so scale by the real frame time.
      const dt = Math.min((time - s.lastTime) / 16.67, 3);
      s.lastTime = time;
      const emitting = s.holding || time < s.until;

      if (emitting && s.particles.length < LIMIT && time - s.lastSpawn > 30) {
        s.lastSpawn = time;
        const size = SIZES[Math.floor(Math.random() * SIZES.length)];
        const el = document.createElement("span");
        const round = shape === "circle" || (shape === "mix" && Math.random() < 0.5);
        el.style.cssText = `position:absolute;left:0;top:0;width:${size}px;height:${round ? size : size * 0.6}px;border-radius:${round ? "50%" : "3px"};background:${colors[Math.floor(Math.random() * colors.length)]};will-change:transform;`;
        layer.appendChild(el);
        s.particles.push({ el, x: s.x - size / 2, y: s.y - size / 2, vx: Math.random() * 9 * (Math.random() < 0.5 ? -1 : 1), vy: 4 + Math.random() * 18, rot: Math.random() * 360, spin: Math.random() * 30 * (Math.random() < 0.5 ? -1 : 1), size });
      }

      const floor = layer.clientHeight;
      s.particles = s.particles.filter((p) => {
        p.x += p.vx * dt;
        p.y -= p.vy * dt;
        p.vy -= dt; // gravity
        p.rot += p.spin * dt;
        if (p.y > floor + p.size) {
          p.el.remove();
          return false;
        }
        p.el.style.transform = `translate3d(${p.x}px, ${p.y}px, 0) rotate(${p.rot}deg)`;
        return true;
      });

      s.frame = s.particles.length || emitting ? requestAnimationFrame(loop) : 0;
    },
    [layerRef, colors, shape],
  );

  const start = useCallback(() => {
    const s = state.current;
    if (s.frame) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    s.lastTime = performance.now();
    s.frame = requestAnimationFrame(loop);
  }, [loop]);

  const aim = (clientX: number, clientY: number) => {
    const box = layerRef.current?.getBoundingClientRect();
    if (!box) return;
    state.current.x = clientX - box.left;
    state.current.y = clientY - box.top;
  };

  /** A short burst from the centre of the button, for the keyboard and the demo. */
  const burst = useCallback(
    (ms = 450) => {
      const r = buttonRef.current?.getBoundingClientRect();
      const box = layerRef.current?.getBoundingClientRect();
      if (!r || !box) return;
      state.current.x = r.left + r.width / 2 - box.left;
      state.current.y = r.top + r.height / 2 - box.top;
      state.current.until = performance.now() + ms;
      start();
    },
    [layerRef, start],
  );

  useEffect(() => {
    if (!autoEvery) return;
    const tick = () => {
      if (idle?.current !== false) burst();
    };
    const first = window.setTimeout(tick, 120);
    const timer = window.setInterval(tick, autoEvery * 1000);
    return () => {
      window.clearTimeout(first);
      window.clearInterval(timer);
    };
  }, [autoEvery, burst, idle]);

  // Stop the loop and clear what is left when the button goes away.
  useEffect(() => {
    const s = state.current;
    return () => {
      cancelAnimationFrame(s.frame);
      s.frame = 0;
      s.particles.forEach((p) => p.el.remove());
      s.particles = [];
    };
  }, []);

  const release = () => {
    state.current.holding = false;
  };

  return (
    <button
      ref={buttonRef}
      type="button"
      className={className}
      onPointerDown={(e: PointerEvent<HTMLButtonElement>) => {
        aim(e.clientX, e.clientY);
        state.current.holding = true;
        start();
      }}
      onPointerMove={(e: PointerEvent<HTMLButtonElement>) => {
        if (state.current.holding) aim(e.clientX, e.clientY);
      }}
      onPointerUp={release}
      onPointerLeave={release}
      onPointerCancel={release}
      onClick={(e) => {
        // detail is 0 when the click came from Enter or Space.
        if (e.detail === 0) burst();
      }}
    >
      {children}
    </button>
  );
}

export default function ButtonThatBurstsParticlesOnPress() {
  const layerRef = useRef<HTMLDivElement>(null);
  const idle = useRef(true);
  const stop = () => {
    idle.current = false;
  };

  return (
    <section className="relative overflow-hidden bg-sky-50 px-5 py-16 sm:px-6 sm:py-24" onPointerDown={stop} onKeyDown={stop}>
      <div className="relative mx-auto max-w-xl rounded-3xl border border-sky-100 bg-white p-7 text-center shadow-xl shadow-sky-900/5 sm:p-10">
        <span className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-100 text-rose-600">
          <PartyPopper aria-hidden="true" className="h-6 w-6" />
        </span>
        <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">The pool fund is full.</h2>
        <p className="mt-3 text-base leading-7 text-slate-600">412 neighbours gave $12,000 in nineteen days. The Millbrook pool reopens in June, heated, with a new slide.</p>

        <div className="mt-7">
          <div className="flex items-baseline justify-between text-sm">
            <span className="font-semibold text-slate-900">$12,000 raised</span>
            <span className="text-slate-600">100% of goal</span>
          </div>
          <div className="mt-2 h-2.5 overflow-hidden rounded-full bg-slate-100">
            <div className="h-full w-full rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-rose-500" />
          </div>
        </div>

        <ParticleButton
          layerRef={layerRef}
          colors={palettes.party}
          shape="mix"
          autoEvery={1.4}
          idle={idle}
          className="mt-8 inline-flex h-14 w-full select-none items-center justify-center rounded-2xl bg-slate-900 px-8 text-base font-semibold text-white transition active:scale-95 hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 motion-reduce:transition-none sm:w-auto"
        >
          Press and hold to celebrate
        </ParticleButton>

        <div className="mt-4 flex flex-wrap justify-center gap-3">
          <ParticleButton layerRef={layerRef} colors={palettes.bubbles} className="inline-flex h-11 select-none items-center rounded-full border border-sky-200 bg-sky-50 px-5 text-sm font-semibold text-sky-800 transition active:scale-95 hover:border-sky-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 focus-visible:ring-offset-2 motion-reduce:transition-none">
            Bubbles
          </ParticleButton>
          <ParticleButton layerRef={layerRef} colors={palettes.embers} shape="square" className="inline-flex h-11 select-none items-center rounded-full border border-orange-200 bg-orange-50 px-5 text-sm font-semibold text-orange-800 transition active:scale-95 hover:border-orange-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 focus-visible:ring-offset-2 motion-reduce:transition-none">
            Embers
          </ParticleButton>
        </div>
      </div>

      {/* Particles are added here. It sits above the card and never blocks clicks. */}
      <div ref={layerRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden" />
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/button-that-bursts-particles-on-press.json

When to use it

How to customize it

Accessibility

Questions

How are the particles made?

Each particle is a small `span` added to a layer that covers the section. A single animation loop per button moves every particle with a transform, applies gravity to its upward speed and removes it once it falls below the layer.

Does the animation loop run all the time?

No. The loop starts on press and ends itself when the button is released and the last particle has fallen out. While nothing is in the air there is no work being done.

Why are the particles inside the section and not the whole window?

Keeping them in a positioned layer with `overflow-hidden` means the effect cannot cover other parts of the page and is removed cleanly with the component. Point `layerRef` at a bigger wrapper if you want a wider throw.

Does it work on touch screens?

Yes. It uses pointer events, so pressing and holding with a finger emits particles from under the finger and following it as it moves across the button.

Can I fire a burst from my own code?

Yes. Inside `ParticleButton` the `burst` function emits for a given number of milliseconds from the button centre. Call it after a successful save, for example.

Adapted from Cool Mode in Magic UI, used under its open-source licence. Licences and credits

Related: Hold to Confirm Button with Progress Ring, Magnetic Button That Follows the Pointer, Subscribe Button with Animated Confirmed State.

More animated buttons or all free UI components. Start for free to have a full site built around it.

More in Animated Buttons

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap