Heart Button
An interactive like button with animated heart icon, counter, and celebration effects when reaching maximum likes.
Preview
Installation
Install dependencies
# Install Shadcn Button component
npx shadcn@latest add button
# Install Framer Motion
npm install framer-motion
# Install Lucide React for icons
npm install lucide-react
Create the component file
mkdir -p components/shsfui/button && touch components/shsfui/button/heart-button.tsx
Add the component code
Open the newly created file and paste the following code:
"use client";
import * as React from "react";
import { Heart } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Button, type ButtonProps } from "@/components/ui/button";
import { cn } from "@/utils/cn";
type HeartButtonProps = ButtonProps & {
initialCount?: number;
initialClickCount?: number;
maxClicks?: number;
label?: string;
onHeartComplete?: () => void;
onCountChange?: (count: number) => void;
};
const HeartButton = React.forwardRef<HTMLButtonElement, HeartButtonProps>(
(props, ref) => {
const {
className,
initialCount = 16,
initialClickCount = 0,
maxClicks = 5,
label = "Like",
onHeartComplete,
onCountChange,
...restProps
} = props;
const [clickCount, setClickCount] = React.useState(initialClickCount);
const [count, setCount] = React.useState(initialCount);
const isCompleted = clickCount >= maxClicks;
const fillPercentage = Math.min(100, (clickCount / maxClicks) * 100);
const isActive = clickCount > 0;
const sizeMultiplier = 1 + clickCount * 0.04;
const animations = {
count: {
initial: { y: -20, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: 20, opacity: 0 },
},
heart: {
initial: { scale: 1 },
tapActive: { scale: 0.8 },
tapCompleted: { scale: 1 },
},
particle: (index: number) => ({
initial: { x: "50%", y: "50%", scale: 0, opacity: 0 },
animate: {
x: `calc(50% + ${Math.cos((index * Math.PI) / 3) * 30}px)`,
y: `calc(50% + ${Math.sin((index * Math.PI) / 3) * 30}px)`,
scale: [0, 1, 0],
opacity: [0, 1, 0],
},
transition: { duration: 0.8, delay: index * 0.05, ease: "easeOut" },
}),
glow: {
initial: { scale: 1, opacity: 0 },
animate: { scale: [1, 1.5], opacity: [0.8, 0] },
transition: { duration: 0.8, ease: "easeOut" },
},
pulse: {
initial: { scale: 1.2, opacity: 0 },
animate: { scale: [1.2, 1.8, 1.2], opacity: [0, 0.3, 0] },
transition: { duration: 1.2, ease: "easeInOut" },
},
};
const handleClick = () => {
if (clickCount < maxClicks) {
const newClickCount = clickCount + 1;
const newCount = count + 1;
setClickCount(newClickCount);
setCount(newCount);
if (onCountChange) {
onCountChange(newCount);
}
if (newClickCount === maxClicks && onHeartComplete) {
onHeartComplete();
}
}
};
return (
<div className="relative">
<Button
ref={ref}
className={cn("py-0 pe-0 overflow-visible", className)}
variant="outline"
onClick={handleClick}
aria-pressed={isActive}
type="button"
{...restProps}
>
<motion.div
initial={{ scale: 1 }}
animate={{ scale: isActive ? sizeMultiplier : 1 }}
whileTap={
isCompleted
? animations.heart.tapCompleted
: animations.heart.tapActive
}
transition={{ type: "spring", stiffness: 300, damping: 15 }}
className="relative"
>
<Heart className="opacity-60" size={16} aria-hidden="true" />
<Heart
className="absolute inset-0 text-red-500 fill-red-500 transition-all duration-300"
size={16}
aria-hidden="true"
style={{ clipPath: `inset(${100 - fillPercentage}% 0 0 0)` }}
/>
<AnimatePresence>
{isCompleted && (
<>
<motion.div
className="absolute inset-0 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(239,68,68,0.4) 0%, rgba(239,68,68,0) 70%)",
}}
{...animations.pulse}
/>
<motion.div
className="absolute inset-0 rounded-full"
style={{ boxShadow: "0 0 10px 2px rgba(239,68,68,0.6)" }}
{...animations.glow}
/>
</>
)}
</AnimatePresence>
</motion.div>
<span className="ml-1.5">{label}</span>
<span className="relative inline-flex items-center justify-center h-full px-3 text-xs font-medium rounded-full text-muted-foreground before:absolute before:inset-0 before:w-px before:bg-border ms-1">
<AnimatePresence mode="wait">
<motion.span
key={count}
variants={animations.count}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
>
{count}
</motion.span>
</AnimatePresence>
</span>
</Button>
<AnimatePresence>
{isCompleted && (
<motion.div className="absolute inset-0 pointer-events-none">
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
className="absolute w-1 h-1 rounded-full bg-red-500"
initial={animations.particle(i).initial}
animate={animations.particle(i).animate}
transition={animations.particle(i).transition}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
);
HeartButton.displayName = "HeartButton";
export HeartButton;