Bookmark 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/bookmark-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 { motion, AnimatePresence } from "framer-motion";
import { Bookmark } from "lucide-react";
import { Button, type ButtonProps } from "@/components/ui/button";
 
type BookmarkButtonProps = ButtonProps & {
  initialState?: boolean;
  onChange?: (isSaved: boolean) => void;
  className?: string;
};
 
const variants = {
  icon: {
    initial: { scale: 1, rotate: 0 },
    active: { scale: 1.1 },
    inactive: { scale: 1 },
    tapActive: { scale: 0.85, rotate: -10 },
    tapInactive: { scale: 1, rotate: 0 },
  },
  burst: {
    initial: { scale: 0, opacity: 0 },
    animate: { scale: [0, 1.4, 1], opacity: [0, 0.4, 0] },
    transition: { duration: 0.7, ease: "easeOut" },
  },
};
 
const createParticleAnimation = (index: number) => {
  const angle = (index / 5) * (2 * Math.PI);
  const radius = 18 + Math.random() * 8;
  const scale = 0.8 + Math.random() * 0.4;
  const duration = 0.6 + Math.random() * 0.1;
 
  return {
    initial: { scale: 0, opacity: 0.3, x: 0, y: 0 },
    animate: {
      scale: [0, scale, 0],
      opacity: [0.3, 0.8, 0],
      x: [0, Math.cos(angle) * radius],
      y: [0, Math.sin(angle) * radius * 0.75],
    },
    transition: { duration, delay: index * 0.04, ease: "easeOut" },
  };
};
 
const BookmarkButton = React.forwardRef<HTMLDivElement, BookmarkButtonProps>(
  (props, ref) => {
    const { initialState = false, onChange, className, ...restProps } = props;
 
    const [isSaved, setIsSaved] = React.useState(initialState);
 
    const handleClick = () => {
      const newState = !isSaved;
      setIsSaved(newState);
      onChange?.(newState);
    };
 
    return (
      <div
        ref={ref}
        className={`relative flex items-center justify-center ${
          className || ""
        }`}
      >
        <Button
          variant="ghost"
          size="icon"
          onClick={handleClick}
          aria-pressed={isSaved}
          aria-label={isSaved ? "Remove bookmark" : "Add bookmark"}
          {...restProps}
        >
          <motion.div
            initial="initial"
            animate={isSaved ? "active" : "inactive"}
            whileTap={isSaved ? "tapInactive" : "tapActive"}
            variants={variants.icon}
            transition={{ type: "spring", stiffness: 300, damping: 15 }}
            className="relative flex items-center justify-center"
          >
            <Bookmark className="opacity-60" size={16} aria-hidden="true" />
 
            <Bookmark
              className="absolute inset-0 text-blue-500 fill-blue-500 transition-all duration-300"
              size={16}
              aria-hidden="true"
              style={{ opacity: isSaved ? 1 : 0 }}
            />
 
            <AnimatePresence>
              {isSaved && (
                <motion.div
                  className="absolute inset-0 rounded-full"
                  style={{
                    background:
                      "radial-gradient(circle, rgba(59,130,246,0.4) 0%, rgba(59,130,246,0) 80%)",
                  }}
                  variants={variants.burst}
                  initial="initial"
                  animate="animate"
                  transition={variants.burst.transition}
                  exit={{ opacity: 0 }}
                />
              )}
            </AnimatePresence>
          </motion.div>
        </Button>
 
        <AnimatePresence>
          {isSaved && (
            <motion.div className="absolute inset-0 flex items-center justify-center pointer-events-none">
              {Array.from({ length: 5 }).map((_, i) => {
                const particle = createParticleAnimation(i);
 
                return (
                  <motion.div
                    key={i}
                    className="absolute rounded-full bg-blue-500"
                    style={{
                      width: `${4 + Math.random() * 2}px`,
                      height: `${4 + Math.random() * 2}px`,
                      filter: "blur(1px)",
                      transform: "translate(-50%, -50%)",
                    }}
                    initial={particle.initial}
                    animate={particle.animate}
                    transition={particle.transition}
                    exit={{ opacity: 0 }}
                  />
                );
              })}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    );
  }
);
 
BookmarkButton.displayName = "BookmarkButton";
 
export { BookmarkButton };
export BookmarkButton;