Web InnoventixFreeCode

Like Heart Toggle

Original · free

heart like toggle with burst

byWeb InnoventixReact + Tailwind
tgllikehearttoggles
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/tgl-like-heart.json
tgl-like-heart.tsx
"use client";

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

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

interface Particle {
  tx: number;
  ty: number;
  size: number;
  color: string;
  delay: number;
}

type ParticleStyle = CSSProperties & Record<"--tx" | "--ty", string>;

const SIZES: Record<HeartSize, { icon: number; dist: number; dot: number }> = {
  sm: { icon: 20, dist: 22, dot: 4 },
  md: { icon: 28, dist: 30, dot: 6 },
  lg: { icon: 46, dist: 46, dot: 8 },
};

const BURST_COLORS = [
  "bg-rose-400",
  "bg-rose-500",
  "bg-amber-400",
  "bg-violet-400",
  "bg-sky-400",
  "bg-emerald-400",
];

function makeBurst(dist: number, dot: number): Particle[] {
  const outer = Array.from({ length: 8 }, (_, i) => {
    const a = (i / 8) * Math.PI * 2;
    return {
      tx: Math.cos(a) * dist,
      ty: Math.sin(a) * dist,
      size: dot,
      color: BURST_COLORS[i % BURST_COLORS.length],
      delay: 0,
    };
  });
  const inner = Array.from({ length: 6 }, (_, i) => {
    const a = ((i + 0.5) / 6) * Math.PI * 2;
    const d = dist * 0.6;
    return {
      tx: Math.cos(a) * d,
      ty: Math.sin(a) * d,
      size: Math.max(3, dot - 2),
      color: BURST_COLORS[(i + 3) % BURST_COLORS.length],
      delay: 0.05,
    };
  });
  return [...outer, ...inner];
}

const KEYFRAMES = `
@keyframes hb-particle {
  0%   { transform: translate(-50%, -50%) scale(0.2); opacity: 0; }
  20%  { opacity: 1; }
  75%  { transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) scale(1); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) scale(0.3); opacity: 0; }
}
@keyframes hb-ring {
  0%   { transform: translate(-50%, -50%) scale(0.35); opacity: 0.65; }
  100% { transform: translate(-50%, -50%) scale(1.7); opacity: 0; }
}
.hb-particle { animation: hb-particle 0.62s ease-out both; }
.hb-ring { animation: hb-ring 0.55s ease-out both; }
@media (prefers-reduced-motion: reduce) {
  .hb-particle, .hb-ring { animation: none !important; }
}
`;

function CountText({
  value,
  reduced,
  className,
}: {
  value: number;
  reduced: boolean;
  className?: string;
}) {
  const text = value.toLocaleString("en-US");
  if (reduced) {
    return <span className={`tabular-nums ${className ?? ""}`}>{text}</span>;
  }
  return (
    <span className={`relative inline-flex overflow-hidden ${className ?? ""}`}>
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={value}
          initial={{ y: "0.9em", opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={{ y: "-0.9em", opacity: 0 }}
          transition={{ duration: 0.22, ease: "easeOut" }}
          className="tabular-nums"
        >
          {text}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function LikeHeart({
  size = "md",
  actionLabel,
  initialLiked = false,
  initialCount = null,
}: {
  size?: HeartSize;
  actionLabel: string;
  initialLiked?: boolean;
  initialCount?: number | null;
}) {
  const prefersReduced = useReducedMotion();
  const reduced = prefersReduced ?? false;
  const [liked, setLiked] = useState<boolean>(initialLiked);
  const cfg = SIZES[size];
  const burst = useMemo(() => makeBurst(cfg.dist, cfg.dot), [cfg.dist, cfg.dot]);

  const count =
    initialCount === null ? null : liked ? initialCount + 1 : initialCount;
  const status = `${liked ? "Liked" : "Not liked"}${
    count === null ? "" : `, ${count.toLocaleString("en-US")} likes`
  }`;

  return (
    <span className="inline-flex items-center">
      <motion.button
        type="button"
        onClick={() => setLiked((v) => !v)}
        aria-pressed={liked}
        aria-label={`${liked ? "Remove like from" : "Like"} ${actionLabel}`}
        whileTap={reduced ? undefined : { scale: 0.9 }}
        className="group relative inline-flex select-none items-center gap-1.5 rounded-full px-2 py-1.5 transition-colors hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-rose-500/10 dark:focus-visible:ring-offset-zinc-900"
      >
        <span
          className="relative inline-flex items-center justify-center"
          style={{ width: cfg.icon, height: cfg.icon }}
        >
          {!reduced && liked && (
            <span
              aria-hidden="true"
              className="pointer-events-none absolute inset-0"
            >
              <span
                className="hb-ring absolute left-1/2 top-1/2 rounded-full border-2 border-rose-400"
                style={{ width: cfg.icon * 1.5, height: cfg.icon * 1.5 }}
              />
              {burst.map((p, i) => {
                const style: ParticleStyle = {
                  width: p.size,
                  height: p.size,
                  animationDelay: `${p.delay}s`,
                  "--tx": `${p.tx}px`,
                  "--ty": `${p.ty}px`,
                };
                return (
                  <span
                    key={i}
                    className={`hb-particle absolute left-1/2 top-1/2 rounded-full ${p.color}`}
                    style={style}
                  />
                );
              })}
            </span>
          )}

          <motion.span
            className={`relative block ${
              liked
                ? "text-rose-500"
                : "text-zinc-400 group-hover:text-rose-400 dark:text-zinc-500 dark:group-hover:text-rose-400"
            }`}
            animate={reduced ? { scale: 1 } : { scale: liked ? [1, 1.35, 0.9, 1] : 1 }}
            transition={
              reduced
                ? { duration: 0 }
                : { duration: 0.5, ease: "easeOut", times: [0, 0.28, 0.52, 1] }
            }
            style={{ width: cfg.icon, height: cfg.icon }}
          >
            <svg
              viewBox="0 0 24 24"
              width={cfg.icon}
              height={cfg.icon}
              fill={liked ? "currentColor" : "none"}
              stroke="currentColor"
              strokeWidth={1.8}
              strokeLinejoin="round"
              strokeLinecap="round"
              className="transition-colors duration-200"
              aria-hidden="true"
            >
              <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
            </svg>
          </motion.span>
        </span>

        {count !== null && (
          <CountText
            value={count}
            reduced={reduced}
            className={`text-sm font-semibold ${
              liked ? "text-rose-500" : "text-zinc-600 dark:text-zinc-300"
            }`}
          />
        )}
      </motion.button>

      <span className="sr-only" role="status" aria-live="polite">
        {status}
      </span>
    </span>
  );
}

function Avatar({ initials, className }: { initials: string; className: string }) {
  return (
    <span
      aria-hidden="true"
      className={`grid h-10 w-10 shrink-0 place-items-center rounded-full text-sm font-semibold ${className}`}
    >
      {initials}
    </span>
  );
}

function Stars({ value }: { value: number }) {
  return (
    <span
      className="inline-flex items-center gap-0.5"
      aria-label={`Rated ${value} out of 5`}
    >
      {Array.from({ length: 5 }, (_, i) => (
        <svg
          key={i}
          viewBox="0 0 20 20"
          fill="currentColor"
          aria-hidden="true"
          className={`h-4 w-4 ${
            i < value ? "text-amber-400" : "text-zinc-300 dark:text-zinc-600"
          }`}
        >
          <path d="M10 1.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79L10 14.77 4.8 17.5l.99-5.79L1.58 7.62l5.82-.85L10 1.5z" />
        </svg>
      ))}
    </span>
  );
}

const COMMENTS = [
  {
    name: "Devin Osei",
    initials: "DO",
    avatar: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300",
    time: "2h",
    text: "This tutorial finally made flexbox click for me. The alignment section alone was worth it.",
    likes: 214,
    liked: true,
  },
  {
    name: "Priya Nair",
    initials: "PN",
    avatar: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
    time: "5h",
    text: "Bookmarking this for the whole team — clearest explanation of stacking contexts I've read.",
    likes: 47,
    liked: false,
  },
];

export default function LikeHeartToggle() {
  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-white to-zinc-50 px-6 py-20 text-zinc-900 dark:from-zinc-950 dark:to-zinc-900 dark:text-zinc-50 sm:px-8 lg:py-28">
      <style>{KEYFRAMES}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72 bg-gradient-to-b from-rose-100/70 to-transparent dark:from-rose-500/10"
      />

      <div className="mx-auto max-w-5xl">
        <header className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-rose-600 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300">
            Micro-interaction
          </span>
          <h2 className="mt-5 text-balance text-3xl font-bold tracking-tight sm:text-4xl">
            One tap to show some love
          </h2>
          <p className="mt-4 text-pretty text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
            A tactile like control with a radiating particle burst, an animated
            count, and full keyboard support. Tap a heart, or focus it and press
            Enter or Space.
          </p>
        </header>

        <div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
          <div className="flex flex-col items-center justify-center gap-4 rounded-3xl border border-zinc-200 bg-white/70 p-8 text-center shadow-sm backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/60 sm:col-span-2 lg:col-span-1">
            <span className="text-xs font-semibold uppercase tracking-widest text-rose-500">
              Wishlist
            </span>
            <LikeHeart
              size="lg"
              actionLabel="the Aria lounge chair"
              initialCount={null}
            />
            <div>
              <p className="text-base font-semibold">Save to favorites</p>
              <p className="mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
                Tap the heart and we&rsquo;ll keep the Aria lounge chair in your
                collection.
              </p>
            </div>
          </div>

          <div className="flex flex-col gap-4 rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 sm:col-span-2">
            <div className="flex items-start gap-3">
              <Avatar
                initials="AW"
                className="bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300"
              />
              <div className="min-w-0 flex-1">
                <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
                  <p className="font-semibold">Amara Whitfield</p>
                  <span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
                    <svg
                      viewBox="0 0 20 20"
                      fill="currentColor"
                      aria-hidden="true"
                      className="h-3 w-3"
                    >
                      <path
                        fillRule="evenodd"
                        d="M16.7 5.3a1 1 0 010 1.4l-7.5 7.5a1 1 0 01-1.4 0L3.3 9.7a1 1 0 011.4-1.4l3.3 3.29 6.8-6.8a1 1 0 011.4 0z"
                        clipRule="evenodd"
                      />
                    </svg>
                    Verified buyer
                  </span>
                </div>
                <div className="mt-1">
                  <Stars value={5} />
                </div>
              </div>
            </div>

            <p className="text-pretty text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
              &ldquo;The ceramic glaze is even better in person &mdash; perfectly
              even, no pooling at the base, and it survived three dinner parties
              without a single chip. Worth every penny.&rdquo;
            </p>

            <div className="flex items-center justify-between border-t border-zinc-100 pt-3 dark:border-zinc-800">
              <LikeHeart
                size="md"
                actionLabel="Amara's review"
                initialCount={1204}
              />
              <div className="flex items-center gap-4 pr-1 text-sm text-zinc-500 dark:text-zinc-400">
                <span className="inline-flex items-center gap-1.5">
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.8}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                    className="h-4 w-4"
                  >
                    <path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
                  </svg>
                  38
                  <span className="sr-only">replies</span>
                </span>
                <span className="inline-flex items-center gap-1.5">
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.8}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                    className="h-4 w-4"
                  >
                    <circle cx="18" cy="5" r="3" />
                    <circle cx="6" cy="12" r="3" />
                    <circle cx="18" cy="19" r="3" />
                    <path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" />
                  </svg>
                  <span className="sr-only">shares</span>
                  12
                </span>
              </div>
            </div>
          </div>

          <div className="rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 sm:col-span-2 lg:col-span-3">
            <p className="mb-2 text-sm font-semibold text-zinc-500 dark:text-zinc-400">
              Recent comments
            </p>
            <ul className="divide-y divide-zinc-100 dark:divide-zinc-800">
              {COMMENTS.map((c) => (
                <li key={c.name} className="flex items-start gap-3 py-4">
                  <Avatar initials={c.initials} className={c.avatar} />
                  <div className="min-w-0 flex-1">
                    <p className="text-sm">
                      <span className="font-semibold">{c.name}</span>
                      <span className="text-zinc-400 dark:text-zinc-500">
                        {" "}
                        &middot; {c.time}
                      </span>
                    </p>
                    <p className="mt-0.5 text-pretty text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
                      {c.text}
                    </p>
                  </div>
                  <div className="pt-0.5">
                    <LikeHeart
                      size="sm"
                      actionLabel={`${c.name}'s comment`}
                      initialLiked={c.liked}
                      initialCount={c.likes}
                    />
                  </div>
                </li>
              ))}
            </ul>
          </div>
        </div>

        <p className="mt-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
          Tip: focus any heart and press{" "}
          <kbd className="rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">
            Space
          </kbd>{" "}
          or{" "}
          <kbd className="rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">
            Enter
          </kbd>{" "}
          to toggle it.
        </p>
      </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 →