# Burst Button

> A like or favourite toggle: the heart pops, a ring expands, sparks fly and the count rolls up.

Category: Buttons · Free · Updated 2026-10-21 · Live demo: https://motionkitui.com/components/burst-button

## When to use it

Likes, favourites, bookmarks and upvotes: any small yes that deserves a little celebration, on posts, products or comments.

## How it works

It's a native button with aria-pressed. Pressing it fills the icon and plays a keyframed pop, while a ring scales out and fades, and eight sparks fly out at alternating distances, so the burst looks natural rather than mechanical. Each press gets its own key, so the burst replays even on quick double taps. The count swaps with AnimatePresence, rolling up when you like and down when you unlike.

## Installation

```bash
npx shadcn@latest add https://motionkitui.com/r/burst-button.json
```

Or copy `components/motionkit/burst-button.tsx` below. Requires react, motion and Tailwind CSS v4.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | `"Like"` | Accessible name. |
| `count` | `number` |  | Count before this visitor's click. Unset hides the number. |
| `pressed` | `boolean` |  | Controlled state. Leave unset to let it manage itself. |
| `onPressedChange` | `(pressed: boolean) => void` |  | Called on every toggle. |
| `color` | `string` | `"#E07A5F"` | Colour of the filled icon, ring and sparks. |
| `iconPath` | `string` |  | An SVG path for a 24 by 24 box. A heart by default. |

## Accessibility

- **Keyboard: Pass.** A native <button> with aria-pressed: Tab to it, Enter or Space toggles, with a visible focus ring.
- **Screen readers: Pass.** Announced as a toggle button with its label and count, e.g. "Like, 129, pressed". The sparks and rolling digits are hidden.
- **Reduced motion: Pass.** With reduced motion on, the icon simply fills: no pop, ring, sparks or rolling count.
- **Colour contrast: Pass.** The outline icon measures 7.1:1 on the button and the count 13.4:1.

## Source

```tsx
"use client";

import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type BurstButtonProps = {
  /** Accessible name, e.g. "Like" or "Save to favourites". */
  label?: string;
  /** Count before this visitor's click. Leave unset to show no number. */
  count?: number;
  /** Controlled state. Leave unset to let the button manage itself. */
  pressed?: boolean;
  onPressedChange?: (pressed: boolean) => void;
  /** Colour of the filled icon, the ring and the sparks. */
  color?: string;
  /** The icon, as an SVG path for a 24 by 24 box. A heart by default. */
  iconPath?: string;
  className?: string;
};

const HEART = "M12 20.5s-7.5-4.6-9.6-9.2C.9 7.9 3.1 4 6.9 4c2.1 0 3.6 1.1 5.1 3 1.5-1.9 3-3 5.1-3 3.8 0 6 3.9 4.5 7.3-2.1 4.6-9.6 9.2-9.6 9.2Z";

// eight sparks around the icon, alternating long and short so the burst reads as natural
const sparks = Array.from({ length: 8 }, (_, index) => ({ angle: (index / 8) * Math.PI * 2 + Math.PI / 8, distance: index % 2 ? 20 : 26 }));

/** A like or favourite toggle: the icon pops, a ring expands, sparks fly and the count rolls up. */
export function BurstButton({
  label = "Like",
  count,
  pressed: controlled,
  onPressedChange,
  color = "#E07A5F",
  iconPath = HEART,
  className = "",
}: BurstButtonProps) {
  const reduceMotion = useReducedMotion();
  const [own, setOwn] = useState(false);
  // every new press gets its own key, so the burst replays even on quick double taps
  const [burst, setBurst] = useState(0);
  const pressed = controlled ?? own;
  const shown = count === undefined ? undefined : count + (pressed ? 1 : 0);

  function toggle() {
    const next = !pressed;
    if (controlled === undefined) setOwn(next);
    onPressedChange?.(next);
    if (next) setBurst((n) => n + 1);
  }

  return (
    <button
      type="button"
      onClick={toggle}
      aria-pressed={pressed}
      aria-label={shown === undefined ? label : `${label}, ${shown}`}
      className={`group inline-flex items-center gap-2 rounded-full border border-white/10 bg-[#141B1B] py-2 pl-2.5 text-sm text-[#DCE3E2] transition-colors hover:border-white/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5FAEB4] ${
        shown === undefined ? "pr-2.5" : "pr-4"
      } ${className}`}
    >
      <span className="relative flex size-6 items-center justify-center">
        {/* the ring and sparks only play on the way to pressed, never on un-pressing */}
        {!reduceMotion && burst > 0 && pressed && (
          <span key={burst} aria-hidden className="pointer-events-none absolute inset-0">
            <motion.span
              className="absolute inset-0 rounded-full border-2"
              style={{ borderColor: color }}
              initial={{ scale: 0.3, opacity: 0.9 }}
              animate={{ scale: 2.1, opacity: 0, borderWidth: 0 }}
              transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
            />
            {sparks.map((spark, index) => (
              <motion.span
                key={index}
                className="absolute left-1/2 top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full"
                style={{ backgroundColor: color }}
                initial={{ x: 0, y: 0, scale: 1, opacity: 1 }}
                animate={{ x: Math.cos(spark.angle) * spark.distance, y: Math.sin(spark.angle) * spark.distance, scale: 0, opacity: 0 }}
                transition={{ duration: 0.6, delay: 0.05, ease: [0.22, 1, 0.36, 1] }}
              />
            ))}
          </span>
        )}
        <motion.svg
          aria-hidden
          viewBox="0 0 24 24"
          className="relative size-5"
          initial={false}
          animate={reduceMotion ? undefined : { scale: pressed ? [0.6, 1.25, 1] : [1, 0.85, 1] }}
          transition={{ duration: 0.45, times: [0, 0.45, 1], ease: "easeOut" }}
        >
          <motion.path
            d={iconPath}
            initial={false}
            animate={{ fill: pressed ? color : "rgb(0 0 0 / 0)", stroke: pressed ? color : "#94A9A7" }}
            transition={{ duration: 0.2 }}
            strokeWidth={1.8}
            strokeLinejoin="round"
          />
        </motion.svg>
      </span>

      {shown !== undefined && (
        <span aria-hidden className="relative inline-flex h-5 min-w-[1.5ch] overflow-hidden tabular-nums">
          {/* the new number rolls in from below when pressing, from above when un-pressing */}
          <AnimatePresence mode="popLayout" initial={false} custom={pressed}>
            <motion.span
              key={shown}
              custom={pressed}
              variants={{
                enter: (up: boolean) => ({ y: reduceMotion ? 0 : up ? "100%" : "-100%", opacity: 0 }),
                center: { y: 0, opacity: 1 },
                exit: (up: boolean) => ({ y: reduceMotion ? 0 : up ? "-100%" : "100%", opacity: 0 }),
              }}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
              className="leading-5"
            >
              {shown.toLocaleString()}
            </motion.span>
          </AnimatePresence>
        </span>
      )}
    </button>
  );
}
```
