A like or favourite toggle: the heart pops, a ring expands, sparks fly and the count rolls up.
Customize
Usage
import { BurstButton } from "@/components/motionkit/burst-button";
<BurstButton count={128}
onPressedChange={(liked) => saveLike(postId, liked)}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Icon pop
Pressed
Ring and sparks
Pressed
Likes, favourites, bookmarks and upvotes: any small yes that deserves a little celebration, on posts, products or comments.
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.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://motionkitui.com/r/burst-button.jsonnpm install motionSave as components/motionkit/burst-button.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>
);
}
| 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. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Only the motion package, beside React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://motionkitui.com/r/burst-button.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, the icon simply fills: no pop, ring, sparks or rolling count.