Like Button
Heart toggle with a particle burst and a rolling counter.
components/ui/like-button.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Heart } from "lucide-react";
import { useState } from "react";
const PARTICLES = Array.from({ length: 8 }, (_, i) => {
const angle = (i / 8) * Math.PI * 2;
return { x: Math.round(Math.cos(angle) * 22), y: Math.round(Math.sin(angle) * 22), color: i % 2 ? "bg-rose-400" : "bg-amber-400" };
});
export function LikeButton({ initialCount = 128, initialLiked = false }: { initialCount?: number; initialLiked?: boolean }) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
const [burst, setBurst] = useState(0);
function toggle() {
setLiked((v) => !v);
setCount((c) => c + (liked ? -1 : 1));
if (!liked) setBurst((b) => b + 1);
}
return (
<button
onClick={toggle}
aria-pressed={liked}
aria-label={liked ? "Unlike" : "Like"}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-colors ${
liked ? "border-rose-200 bg-rose-50 text-rose-600" : "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50"
}`}
>
<span className="relative grid size-5 place-items-center">
{burst > 0 && liked && (
<span key={burst} aria-hidden className="absolute inset-0">
<motion.span
className="absolute inset-0 rounded-full border-2 border-rose-400"
initial={{ scale: 0.2, opacity: 1 }}
animate={{ scale: 2, opacity: 0 }}
transition={{ duration: 0.5 }}
/>
{PARTICLES.map((p, i) => (
<motion.span
key={i}
className={`absolute left-1/2 top-1/2 -ml-[3px] -mt-[3px] size-1.5 rounded-full ${p.color}`}
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
animate={{ x: p.x, y: p.y, opacity: 0, scale: 0.4 }}
transition={{ duration: 0.6, ease: "easeOut" }}
/>
))}
</span>
)}
<motion.span key={liked ? "on" : "off"} initial={{ scale: 0.5 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 500, damping: 14 }}>
<Heart className={`size-5 ${liked ? "fill-rose-500 text-rose-500" : "text-zinc-500"}`} />
</motion.span>
</span>
<span className="relative inline-flex h-5 overflow-hidden tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={count}
initial={{ y: liked ? 16 : -16, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: liked ? -16 : 16, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{count}
</motion.span>
</AnimatePresence>
</span>
</button>
);
}
export default function Demo() {
return (
<div className="flex items-center gap-3">
<LikeButton />
<LikeButton initialCount={42} initialLiked />
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/like-button.tsxcomponents/ui/like-button.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { Heart } from "lucide-react"; import { useState } from "react"; const PARTICLES = Array.from({ length: 8 }, (_, i) => { const angle = (i / 8) * Math.PI * 2; return { x: Math.round(Math.cos(angle) * 22), y: Math.round(Math.sin(angle) * 22), color: i % 2 ? "bg-rose-400" : "bg-amber-400" }; }); export function LikeButton({ initialCount = 128, initialLiked = false }: { initialCount?: number; initialLiked?: boolean }) { const [liked, setLiked] = useState(initialLiked); const [count, setCount] = useState(initialCount); const [burst, setBurst] = useState(0); function toggle() { setLiked((v) => !v); setCount((c) => c + (liked ? -1 : 1)); if (!liked) setBurst((b) => b + 1); } return ( <button onClick={toggle} aria-pressed={liked} aria-label={liked ? "Unlike" : "Like"} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-colors ${ liked ? "border-rose-200 bg-rose-50 text-rose-600" : "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50" }`} > <span className="relative grid size-5 place-items-center"> {burst > 0 && liked && ( <span key={burst} aria-hidden className="absolute inset-0"> <motion.span className="absolute inset-0 rounded-full border-2 border-rose-400" initial={{ scale: 0.2, opacity: 1 }} animate={{ scale: 2, opacity: 0 }} transition={{ duration: 0.5 }} /> {PARTICLES.map((p, i) => ( <motion.span key={i} className={`absolute left-1/2 top-1/2 -ml-[3px] -mt-[3px] size-1.5 rounded-full ${p.color}`} initial={{ x: 0, y: 0, opacity: 1, scale: 1 }} animate={{ x: p.x, y: p.y, opacity: 0, scale: 0.4 }} transition={{ duration: 0.6, ease: "easeOut" }} /> ))} </span> )} <motion.span key={liked ? "on" : "off"} initial={{ scale: 0.5 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 500, damping: 14 }}> <Heart className={`size-5 ${liked ? "fill-rose-500 text-rose-500" : "text-zinc-500"}`} /> </motion.span> </span> <span className="relative inline-flex h-5 overflow-hidden tabular-nums"> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={count} initial={{ y: liked ? 16 : -16, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: liked ? -16 : 16, opacity: 0 }} transition={{ duration: 0.2 }} > {count} </motion.span> </AnimatePresence> </span> </button> ); } export default function Demo() { return ( <div className="flex items-center gap-3"> <LikeButton /> <LikeButton initialCount={42} initialLiked /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.