Web InnoventixFreeCode

Like Button

Original · free

A like button that toggles with a heart burst and a live count.

byWeb InnoventixReact + Tailwind
btnlikebuttons
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/btn-like.json
btn-like.tsx
"use client";

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

type Size = "sm" | "md" | "lg";

const SIZES: Record<
  Size,
  { pad: string; icon: string; text: string; gap: string; count: string }
> = {
  sm: { pad: "px-3 py-1.5", icon: "h-4 w-4", text: "text-xs", gap: "gap-1.5", count: "text-xs" },
  md: { pad: "px-4 py-2", icon: "h-5 w-5", text: "text-sm", gap: "gap-2", count: "text-sm" },
  lg: { pad: "px-5 py-2.5", icon: "h-6 w-6", text: "text-base", gap: "gap-2.5", count: "text-base" },
};

function Heart({ filled, className }: { filled: boolean; className: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      className={className}
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 1 0-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 0 0 0-7.78z" />
    </svg>
  );
}

const BURST = [
  { x: 0, y: -22, s: 1, d: 0 },
  { x: 18, y: -12, s: 0.7, d: 0.02 },
  { x: 22, y: 6, s: 0.55, d: 0.04 },
  { x: 12, y: 20, s: 0.8, d: 0.01 },
  { x: -12, y: 20, s: 0.6, d: 0.03 },
  { x: -22, y: 6, s: 0.75, d: 0.05 },
  { x: -18, y: -12, s: 0.6, d: 0.02 },
];

function formatCount(n: number): string {
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
  if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
  return String(n);
}

function LikeButton({
  size = "md",
  base = 0,
  initiallyLiked = false,
  label = "Like",
}: {
  size?: Size;
  base?: number;
  initiallyLiked?: boolean;
  label?: string;
}) {
  const reduce = useReducedMotion();
  const [liked, setLiked] = useState(initiallyLiked);
  const [burstKey, setBurstKey] = useState(0);
  const s = SIZES[size];
  const count = base + (liked ? 1 : 0);

  const toggle = () => {
    setLiked((v) => {
      if (!v) setBurstKey((k) => k + 1);
      return !v;
    });
  };

  return (
    <button
      type="button"
      onClick={toggle}
      aria-pressed={liked}
      aria-label={liked ? "Unlike" : label}
      className={[
        "group relative inline-flex select-none items-center rounded-full font-semibold",
        "border transition-all duration-200 active:scale-[0.96]",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
        "focus-visible:ring-rose-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        s.pad,
        s.text,
        s.gap,
        liked
          ? "border-rose-200 bg-rose-50 text-rose-600 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-400"
          : "border-slate-200 bg-white text-slate-600 hover:border-rose-200 hover:text-rose-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-rose-500/40 dark:hover:text-rose-400",
      ].join(" ")}
    >
      <span className="relative inline-flex items-center justify-center">
        <motion.span
          key={liked ? "on" : "off"}
          initial={reduce ? false : { scale: liked ? 0.4 : 1 }}
          animate={{ scale: 1 }}
          transition={
            reduce
              ? { duration: 0 }
              : { type: "spring", stiffness: 600, damping: 15, mass: 0.5 }
          }
          className="inline-flex"
        >
          <Heart filled={liked} className={s.icon} />
        </motion.span>

        <AnimatePresence>
          {liked && !reduce && (
            <span
              key={burstKey}
              className="pointer-events-none absolute inset-0 flex items-center justify-center"
            >
              <motion.span
                initial={{ scale: 0, opacity: 0.7 }}
                animate={{ scale: 2.2, opacity: 0 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.5, ease: "easeOut" }}
                className="absolute h-6 w-6 rounded-full bg-rose-400/40"
              />
              {BURST.map((p, i) => (
                <motion.span
                  key={i}
                  initial={{ x: 0, y: 0, scale: 0, opacity: 1 }}
                  animate={{ x: p.x, y: p.y, scale: p.s, opacity: 0 }}
                  transition={{ duration: 0.55, delay: p.d, ease: "easeOut" }}
                  className="absolute h-1.5 w-1.5 rounded-full bg-rose-500"
                />
              ))}
            </span>
          )}
        </AnimatePresence>
      </span>

      <span className="relative overflow-hidden">
        <span className="tabular-nums" aria-hidden="true">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={count}
              initial={reduce ? false : { y: liked ? 10 : -10, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={reduce ? { opacity: 0 } : { y: liked ? -10 : 10, opacity: 0 }}
              transition={{ duration: 0.2 }}
              className={["inline-block", s.count].join(" ")}
            >
              {formatCount(count)}
            </motion.span>
          </AnimatePresence>
        </span>
      </span>

      <span className="sr-only" aria-live="polite">
        {formatCount(count)} likes
      </span>
    </button>
  );
}

function IconLikeButton({
  size = "md",
  initiallyLiked = false,
}: {
  size?: Size;
  initiallyLiked?: boolean;
}) {
  const reduce = useReducedMotion();
  const [liked, setLiked] = useState(initiallyLiked);
  const [burstKey, setBurstKey] = useState(0);
  const dim = size === "sm" ? "h-9 w-9" : size === "lg" ? "h-14 w-14" : "h-11 w-11";
  const ic = SIZES[size].icon;

  return (
    <button
      type="button"
      onClick={() =>
        setLiked((v) => {
          if (!v) setBurstKey((k) => k + 1);
          return !v;
        })
      }
      aria-pressed={liked}
      aria-label={liked ? "Unlike" : "Like"}
      className={[
        "relative inline-flex items-center justify-center rounded-full border transition-all duration-200 active:scale-90",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2",
        "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        dim,
        liked
          ? "border-rose-200 bg-rose-500 text-white shadow-lg shadow-rose-500/25 dark:border-rose-500/40"
          : "border-slate-200 bg-white text-slate-500 hover:text-rose-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:text-rose-400",
      ].join(" ")}
    >
      <motion.span
        key={liked ? "on" : "off"}
        initial={reduce ? false : { scale: liked ? 0.4 : 1 }}
        animate={{ scale: 1 }}
        transition={
          reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 14, mass: 0.5 }
        }
        className="inline-flex"
      >
        <Heart filled={liked} className={ic} />
      </motion.span>

      <AnimatePresence>
        {liked && !reduce && (
          <span
            key={burstKey}
            className="pointer-events-none absolute inset-0 flex items-center justify-center"
          >
            {BURST.map((p, i) => (
              <motion.span
                key={i}
                initial={{ x: 0, y: 0, scale: 0, opacity: 1 }}
                animate={{ x: p.x, y: p.y, scale: p.s, opacity: 0 }}
                transition={{ duration: 0.6, delay: p.d, ease: "easeOut" }}
                className="absolute h-1.5 w-1.5 rounded-full bg-rose-400"
              />
            ))}
          </span>
        )}
      </AnimatePresence>
    </button>
  );
}

function PillLikeButton() {
  const reduce = useReducedMotion();
  const [liked, setLiked] = useState(true);
  const [burstKey, setBurstKey] = useState(0);
  const count = 2847 + (liked ? 0 : -1);

  return (
    <button
      type="button"
      onClick={() =>
        setLiked((v) => {
          if (!v) setBurstKey((k) => k + 1);
          return !v;
        })
      }
      aria-pressed={liked}
      aria-label={liked ? "Remove like from “Midnight Skyline” print" : "Like “Midnight Skyline” print"}
      className={[
        "group relative inline-flex items-center gap-2.5 rounded-full px-5 py-2.5 text-sm font-semibold",
        "transition-all duration-200 active:scale-[0.97]",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
        "focus-visible:ring-rose-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        liked
          ? "bg-gradient-to-r from-rose-500 to-pink-500 text-white shadow-lg shadow-rose-500/30"
          : "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700",
      ].join(" ")}
    >
      <span className="relative inline-flex">
        <motion.span
          key={liked ? "on" : "off"}
          initial={reduce ? false : { scale: liked ? 0.4 : 1 }}
          animate={{ scale: 1 }}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 15 }}
          className="inline-flex"
        >
          <Heart filled={liked} className="h-5 w-5" />
        </motion.span>
        <AnimatePresence>
          {liked && !reduce && (
            <span key={burstKey} className="pointer-events-none absolute inset-0 flex items-center justify-center">
              {BURST.map((p, i) => (
                <motion.span
                  key={i}
                  initial={{ x: 0, y: 0, scale: 0, opacity: 1 }}
                  animate={{ x: p.x, y: p.y, scale: p.s, opacity: 0 }}
                  transition={{ duration: 0.55, delay: p.d, ease: "easeOut" }}
                  className="absolute h-1.5 w-1.5 rounded-full bg-white"
                />
              ))}
            </span>
          )}
        </AnimatePresence>
      </span>
      <span>{liked ? "Liked" : "Like"}</span>
      <span className="h-4 w-px bg-current opacity-20" aria-hidden="true" />
      <span className="tabular-nums" aria-hidden="true">
        {formatCount(count)}
      </span>
      <span className="sr-only" aria-live="polite">
        {formatCount(count)} people liked this print
      </span>
    </button>
  );
}

function PhotoCardLike() {
  const reduce = useReducedMotion();
  const [liked, setLiked] = useState(false);
  const [burstKey, setBurstKey] = useState(0);
  const count = 1204 + (liked ? 1 : 0);
  const capId = useId();

  const like = () =>
    setLiked((v) => {
      if (!v) setBurstKey((k) => k + 1);
      return !v;
    });

  return (
    <figure
      className="w-64 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
      aria-labelledby={capId}
    >
      <div className="relative">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src="/img/gallery/g14.webp"
          alt="Aerial photograph of a winding coastal road at golden hour"
          loading="lazy"
          draggable={false}
          className="h-40 w-full object-cover"
        />
        <div className="absolute right-3 top-3">
          <IconLikeButton size="sm" />
        </div>
      </div>
      <figcaption id={capId} className="flex items-center justify-between gap-2 p-3">
        <div className="min-w-0">
          <p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
            Coastal Route 1
          </p>
          <p className="truncate text-xs text-slate-500 dark:text-slate-400">by Elena Marsh</p>
        </div>
        <button
          type="button"
          onClick={like}
          aria-pressed={liked}
          aria-label={liked ? "Unlike Coastal Route 1" : "Like Coastal Route 1"}
          className={[
            "relative inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2",
            "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
            liked
              ? "text-rose-500 dark:text-rose-400"
              : "text-slate-500 hover:text-rose-500 dark:text-slate-400 dark:hover:text-rose-400",
          ].join(" ")}
        >
          <span className="relative inline-flex">
            <Heart filled={liked} className="h-4 w-4" />
            <AnimatePresence>
              {liked && !reduce && (
                <span key={burstKey} className="pointer-events-none absolute inset-0 flex items-center justify-center">
                  {BURST.map((p, i) => (
                    <motion.span
                      key={i}
                      initial={{ x: 0, y: 0, scale: 0, opacity: 1 }}
                      animate={{ x: p.x * 0.7, y: p.y * 0.7, scale: p.s, opacity: 0 }}
                      transition={{ duration: 0.5, delay: p.d, ease: "easeOut" }}
                      className="absolute h-1 w-1 rounded-full bg-rose-500"
                    />
                  ))}
                </span>
              )}
            </AnimatePresence>
          </span>
          <span className="tabular-nums">{formatCount(count)}</span>
        </button>
      </figcaption>
    </figure>
  );
}

export default function BtnLike() {
  return (
    <section className="relative w-full bg-white px-6 py-20 dark:bg-slate-950 sm:px-10">
      <style>{`
        @media (prefers-reduced-motion: reduce) {
          .btnlike-hint { animation: none !important; }
        }
        @keyframes btnlike-pulse {
          0%, 100% { opacity: 0.35; }
          50% { opacity: 0.9; }
        }
        .btnlike-hint { animation: btnlike-pulse 2.4s ease-in-out infinite; }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-14 text-center">
          <p className="btnlike-hint mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-rose-500 dark:text-rose-400">
            FreeCode / Buttons
          </p>
          <h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50 sm:text-4xl">
            Like Button
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-slate-600 dark:text-slate-400">
            Tap to toggle. Every like fires a heart burst and bumps a live, formatted count.
          </p>
        </header>

        <div className="space-y-12">
          <div>
            <h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
              With count · sizes
            </h3>
            <div className="flex flex-wrap items-center gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-6 dark:border-slate-800 dark:bg-slate-900/50">
              <LikeButton size="sm" base={128} />
              <LikeButton size="md" base={2400} initiallyLiked />
              <LikeButton size="lg" base={18900} label="Like this post" />
            </div>
          </div>

          <div>
            <h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
              Icon only
            </h3>
            <div className="flex flex-wrap items-center gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-6 dark:border-slate-800 dark:bg-slate-900/50">
              <IconLikeButton size="sm" />
              <IconLikeButton size="md" initiallyLiked />
              <IconLikeButton size="lg" />
            </div>
          </div>

          <div>
            <h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
              Gradient pill
            </h3>
            <div className="flex flex-wrap items-center gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-6 dark:border-slate-800 dark:bg-slate-900/50">
              <PillLikeButton />
            </div>
          </div>

          <div>
            <h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
              In context
            </h3>
            <div className="flex flex-wrap items-center gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-6 dark:border-slate-800 dark:bg-slate-900/50">
              <PhotoCardLike />
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →