like-button

PreviousNext
Docs API Reference
uicapsuleblock

Preview

Loading preview…
/like-button.tsx
"use client";

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

const CircleAnimation = () => {
  const CIRCLE_RADIUS = 20;

  return (
    <svg
      className="pointer-events-none absolute -top-3 -left-3"
      style={{
        width: CIRCLE_RADIUS * 2,
        height: 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: [0.33, 1, 0.68, 1], // cubic-out
        }}
      />
    </svg>
  );
};

// Burst animation with particles
const BurstAnimation = () => {
  // Colors for particles with from/to transitions
  const colorPairs = [
    { 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" },
  ];

  return (
    <div className="pointer-events-none absolute -top-3 -left-3 grid size-10 place-items-center">
      {colorPairs.map((colors, index) => (
        <Particle
          key={index}
          fromColor={colors.from}
          toColor={colors.to}
          index={index}
          totalParticles={colorPairs.length}
        />
      ))}
    </div>
  );
};

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

// Particle component for burst animation
const Particle = ({
  fromColor,
  toColor,
  index,
  totalParticles,
}: {
  fromColor: string;
  toColor: string;
  index: number;
  totalParticles: number;
}) => {
  // Calculate angle based on index with 45 degree offset
  const angle = (index / totalParticles) * 360 + 45;
  const radians = (angle * Math.PI) / 180;

  // Add randomness to the burst distance (±15%)
  const randomFactor = 0.85 + Math.random() * 0.3;
  const burstDistance = BURST_RADIUS * randomFactor;

  // Randomize duration between 500-700ms
  const duration = 500 + Math.random() * 200;

  // 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={{
        opacity: 0,
        scale: 1,
        x: Math.cos(radians) * START_RADIUS * PATH_SCALE_FACTOR,
        y: Math.sin(radians) * START_RADIUS * PATH_SCALE_FACTOR,
        backgroundColor: fromColor,
      }}
      animate={{
        opacity: [0, 1, 1, 0],
        x: Math.cos(radians + degreeShift) * burstDistance * PATH_SCALE_FACTOR,
        y: Math.sin(radians + degreeShift) * burstDistance * PATH_SCALE_FACTOR,
        scale: 0,
        backgroundColor: toColor,
      }}
      transition={{
        opacity: {
          times: [0, 0.01, 0.99, 1],
          duration: duration / 1000,
          delay: 0.4,
        },
        x: {
          duration: duration / 1000,
          ease: [0.23, 1, 0.32, 1], // quint.out for movement
          delay: 0.3,
        },
        y: {
          duration: duration / 1000,
          ease: [0.23, 1, 0.32, 1], // quint.out for movement
          delay: 0.3,
        },
        scale: {
          duration: duration / 1000,
          ease: [0.55, 0.085, 0.68, 0.53], // quad.in for scaling
          delay: 0.3,
        },
        backgroundColor: {
          duration: duration / 1000,
          delay: 0.3,
        },
      }}
    />
  );
};

export const LikeButton = () => {
  const [likeCount, setLikeCount] = useState(0);
  const [isLiked, setIsLiked] = useState(false);
  const [isAnimating, setIsAnimating] = useState(false);
  const iconButtonRef = useRef<null | HTMLButtonElement>(null);

  const toggleLike = () => {
    if (isLiked) {
      setLikeCount(likeCount - 1);
      setIsLiked(false);
    } else {
      setLikeCount(likeCount + 1);
      setIsLiked(true);
      setIsAnimating(true);
    }
  };

  return (
    <button
      ref={iconButtonRef}
      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 />}
        {isAnimating && <BurstAnimation />}
        {isAnimating ? (
          <motion.svg
            key="animating-heart"
            initial={{ scale: 0 }}
            animate={{ scale: 1 }}
            transition={{
              type: "spring",
              stiffness: 300,
              damping: 10,
              delay: 0.3,
            }}
            onAnimationComplete={() => setIsAnimating(false)}
            className="text-red-500"
            width="16"
            height="16"
            viewBox="0 0 24 24"
            stroke="currentColor"
            fill="currentColor"
          >
            <path d="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" />
          </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="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" />
          </svg>
        )}
      </div>
      <span className="min-w-[0.75rem]">
        <NumberFlow value={likeCount} />
        <span className="sr-only">
          {" "}
          likes, click to {isLiked ? "unlike" : "like"}
        </span>
      </span>
    </button>
  );
};

Installation

npx shadcn@latest add @uicapsule/like-button

Usage

import { LikeButton } from "@/components/like-button"
<LikeButton />