Like Button

Heart toggle with a particle burst and a rolling counter.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/like-button.tsx

    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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.