Heart Icon Button
A toggle-able bookmark button with animated icon and counter effects, perfect for favoriting or bookmarkring content.
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/icon-button && touch components/shsfui/icon-button/heart-icon-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 = {
maxClicks?: number;
initialCount?: number;
onChange?: (count: number) => void;
className?: string;
};
const variants = {
heart: {
initial: { scale: 1 },
tapActive: { scale: 0.8 },
tapCompleted: { scale: 1 },
},
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 createParticleAnimation = (index: number) => ({
initial: { x: 0, y: 0, scale: 0, opacity: 0 },
animate: {
x: `calc(${Math.cos((index * Math.PI) / 3) * 30}px)`,
y: `calc(${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" },
});
const HeartButton = React.forwardRef<HTMLDivElement, HeartButtonProps>(
(props, ref) => {
const {
maxClicks = 5,
initialCount = 0,
onChange,
className,
...restProps
} = props;
const [clickCount, setClickCount] = React.useState(initialCount);
const fillPercentage = Math.min(100, (clickCount / maxClicks) * 100);
const isActive = clickCount > 0;
const isCompleted = clickCount >= maxClicks;
const sizeMultiplier = 1 + clickCount * 0.04;
const handleClick = () => {
if (clickCount < maxClicks) {
const newCount = clickCount + 1;
setClickCount(newCount);
onChange?.(newCount);
}
};
return (
<div
ref={ref}
className={cn("flex justify-center items-center relative", className)}
>
<Button
size="icon"
variant="ghost"
onClick={handleClick}
aria-pressed={isActive}
aria-label={isCompleted ? "Maximum hearts given" : "Give heart"}
className="relative"
{...restProps}
>
<motion.div
initial="initial"
animate={{ scale: isActive ? sizeMultiplier : 1 }}
whileTap={isCompleted ? "tapCompleted" : "tapActive"}
variants={variants.heart}
transition={{ type: "spring", stiffness: 300, damping: 15 }}
className="relative"
>
<Heart className="opacity-60" size={24} aria-hidden="true" />
<Heart
className="absolute inset-0 text-red-500 fill-red-500 transition-all duration-300"
size={24}
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%)",
}}
initial="initial"
animate="animate"
exit={{ opacity: 0 }}
variants={variants.pulse}
/>
<motion.div
className="absolute inset-0 rounded-full"
style={{ boxShadow: "0 0 10px 2px rgba(239,68,68,0.6)" }}
initial="initial"
animate="animate"
exit={{ opacity: 0 }}
variants={variants.glow}
/>
</>
)}
</AnimatePresence>
</motion.div>
</Button>
<AnimatePresence>
{isCompleted && (
<motion.div className="absolute inset-0 pointer-events-none flex justify-center items-center">
{Array.from({ length: 6 }).map((_, i) => {
const particle = createParticleAnimation(i);
return (
<motion.div
key={i}
className="absolute w-1 h-1 rounded-full bg-red-500"
initial={particle.initial}
animate={particle.animate}
transition={particle.transition}
exit={{ opacity: 0 }}
/>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
);
HeartButton.displayName = "HeartButton";
export { HeartButton };
export HeartButton;