# Like Button

> Twitter style like button

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/like-button).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/like-button
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/like-button-light-88ed8adf95862a37.png
- Registry item: https://uicapsule.com/r/like-button.json
- Categories: controls, colorful
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/like-button.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@number-flow/react`, `motion`

## Files

### `/like-button.tsx`

```tsx
"use client";

import { useState } from "react";
import NumberFlow from "@number-flow/react";
import { motion } from "motion/react";

const HEART_PATH =
  "m18.199 2.04c-2.606-.284-4.262.961-6.199 3.008-2.045-2.047-3.593-3.292-6.199-3.008-3.544.388-6.321 4.43-5.718 7.96.966 5.659 5.944 9 11.917 12 5.973-3 10.951-6.341 11.917-12 .603-3.53-2.174-7.572-5.718-7.96z";

const CIRCLE_RADIUS = 20;

const CUBIC_OUT = [0.33, 1, 0.68, 1] as const;
const QUAD_IN = [0.55, 0.085, 0.68, 0.53] as const;
const QUINT_OUT = [0.23, 1, 0.32, 1] as const;

const CircleAnimation = () => (
  <svg
    className="pointer-events-none absolute -top-3 -left-3"
    style={{
      height: CIRCLE_RADIUS * 2,
      width: CIRCLE_RADIUS * 2,
    }}
  >
    <motion.circle
      cx={CIRCLE_RADIUS}
      cy={CIRCLE_RADIUS}
      r={CIRCLE_RADIUS - 2}
      fill="none"
      initial={{
        scale: 0,
        stroke: "#E5214A",
        strokeWidth: CIRCLE_RADIUS * 2,
      }}
      animate={{
        scale: 1,
        stroke: "#CC8EF5",
        strokeWidth: 0,
      }}
      transition={{
        duration: 0.4,
        ease: CUBIC_OUT,
      }}
    />
  </svg>
);

// One entry per particle; each fades from `from` to `to` over its flight.
const PARTICLE_COLOR_PAIRS = [
  { from: "#9EC9F5", to: "#9ED8C6" },
  { from: "#91D3F7", to: "#9AE4CF" },
  { from: "#DC93CF", to: "#E3D36B" },
  { from: "#CF8EEF", to: "#CBEB98" },
  { from: "#87E9C6", to: "#1FCC93" },
  { from: "#A7ECD0", to: "#9AE4CF" },
  { from: "#87E9C6", to: "#A635D9" },
  { from: "#D58EB3", to: "#E0B6F5" },
  { from: "#F48BA2", to: "#CF8EEF" },
  { from: "#91D3F7", to: "#A635D9" },
  { from: "#CF8EEF", to: "#CBEB98" },
  { from: "#87E9C6", to: "#A635D9" },
  { from: "#9EC9F5", to: "#9ED8C6" },
  { from: "#91D3F7", to: "#9AE4CF" },
].map((colors, index, pairs) => ({
  angle: (index / pairs.length) * 360 + 45,
  from: colors.from,
  to: colors.to,
}));

const BURST_RADIUS = 32;
const START_RADIUS = 4;
const PATH_SCALE_FACTOR = 0.8;

interface Flight {
  angle: number;
  from: string;
  to: string;
  burstDistance: number;
  duration: number;
}

// Rolled once per burst, in the click handler rather than in render: a re-render
// mid-flight would otherwise hand motion new targets and jolt the particles.
const rollBurst = (): Flight[] =>
  PARTICLE_COLOR_PAIRS.map((colors) => ({
    ...colors,
    // Add randomness to the burst distance (±15%)
    burstDistance: BURST_RADIUS * (0.85 + Math.random() * 0.3),
    // Randomize duration between 500-700ms
    duration: 500 + Math.random() * 200,
  }));

const Particle = ({ from: fromColor, to: toColor, angle, burstDistance, duration }: Flight) => {
  const radians = (angle * Math.PI) / 180;

  // Calculate the degree shift (13 degrees in radians)
  const degreeShift = (13 * Math.PI) / 180;

  return (
    <motion.div
      className="pointer-events-none absolute size-1.5 rounded-full"
      style={{ backgroundColor: fromColor, opacity: 0 }}
      initial={{
        backgroundColor: fromColor,
        opacity: 0,
        scale: 1,
        x: Math.cos(radians) * START_RADIUS * PATH_SCALE_FACTOR,
        y: Math.sin(radians) * START_RADIUS * PATH_SCALE_FACTOR,
      }}
      animate={{
        backgroundColor: toColor,
        opacity: [0, 1, 1, 0],
        scale: 0,
        x: Math.cos(radians + degreeShift) * burstDistance * PATH_SCALE_FACTOR,
        y: Math.sin(radians + degreeShift) * burstDistance * PATH_SCALE_FACTOR,
      }}
      transition={{
        backgroundColor: {
          delay: 0.3,
          duration: duration / 1000,
        },
        opacity: {
          delay: 0.4,
          duration: duration / 1000,
          times: [0, 0.01, 0.99, 1],
        },
        scale: {
          delay: 0.3,
          duration: duration / 1000,
          ease: QUAD_IN,
        },
        x: {
          delay: 0.3,
          duration: duration / 1000,
          ease: QUINT_OUT,
        },
        y: {
          delay: 0.3,
          duration: duration / 1000,
          ease: QUINT_OUT,
        },
      }}
    />
  );
};

const BurstAnimation = ({ flights }: { flights: Flight[] }) => (
  <div className="pointer-events-none absolute -top-3 -left-3 grid size-10 place-items-center">
    {flights.map((flight) => (
      <Particle key={flight.angle} {...flight} />
    ))}
  </div>
);

export const LikeButton = () => {
  const [isLiked, setIsLiked] = useState(false);
  const [burst, setBurst] = useState<Flight[] | null>(null);
  const isAnimating = burst !== null;

  const toggleLike = () => {
    if (isLiked) {
      setIsLiked(false);
    } else {
      setIsLiked(true);
      setBurst(rollBurst());
    }
  };

  return (
    <button
      type="button"
      className="hover:bg-accent relative flex h-8 cursor-pointer items-center gap-1.5 rounded-lg p-2 transition"
      onClick={toggleLike}
    >
      <div className="relative">
        {isAnimating && <CircleAnimation />}
        {burst && <BurstAnimation flights={burst} />}
        {isAnimating ? (
          <motion.svg
            key="animating-heart"
            initial={{ scale: 0 }}
            animate={{ scale: 1 }}
            transition={{
              damping: 10,
              delay: 0.3,
              stiffness: 300,
              type: "spring",
            }}
            onAnimationComplete={() => setBurst(null)}
            className="text-red-500"
            width="16"
            height="16"
            viewBox="0 0 24 24"
            stroke="currentColor"
            fill="currentColor"
          >
            <path d={HEART_PATH} />
          </motion.svg>
        ) : (
          <svg
            className={isLiked ? "text-red-500" : "text-inherit"}
            width="16"
            height="16"
            viewBox="0 0 24 24"
            stroke="currentColor"
            fill="currentColor"
          >
            <path d={HEART_PATH} />
          </svg>
        )}
      </div>
      <span className="min-w-[0.75rem]">
        <NumberFlow value={isLiked ? 1 : 0} />
        <span className="sr-only"> likes, click to {isLiked ? "unlike" : "like"}</span>
      </span>
    </button>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import { LikeButton } from "./like-button";

const Preview = () => (
  <div className="flex h-screen items-center justify-center text-gray-400">
    <LikeButton />
  </div>
);

export default Preview;
```
