Docs

Staggered Card

A card component that displays a list of links with an staggered opening animation.

requires interactionrequires configtogglehover

Installation

Install dependencies

npm install framer-motion

Run the following command

It will create a new file staggered-card.tsx inside the components/animata/card directory.

mkdir -p components/animata/card && touch components/animata/card/staggered-card.tsx

Paste the code

Open the newly created file and paste the following code:

import { useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
 
import { cn } from "@/lib/utils";
 
interface StaggeredCardProps extends React.ComponentProps<"div"> {
  /**
   * List of links to display
   */
  links: {
    label: string;
    href: string;
  }[];
  /**
   * How much to delay each child
   */
  delay?: number;
  /**
   * How much to delay the opening animation (useful for making the animation feel more interactive)
   */
  openingDelay?: number;
}
 
export default function StaggeredCard({
  links,
  className,
  delay = 0.06,
  openingDelay = 0.1,
  ...props
}: StaggeredCardProps) {
  const easeOut = [0, 0, 0.2, 1];
 
  const [open, setOpen] = useState(false);
  const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
 
  const containerRef = useRef<HTMLDivElement>(null);
  const containerRect = containerRef.current?.getBoundingClientRect();
 
  function updateHoverRect(e: React.MouseEvent<HTMLLIElement, MouseEvent>) {
    setHoverRect(e.currentTarget.getBoundingClientRect());
  }
 
  function resetHoverRect() {
    setHoverRect(null);
  }
 
  const toggleOpen = () => {
    setOpen((prev) => !prev);
  };
 
  return (
    <div className={cn("relative h-fit w-fit", className)} {...props}>
      {/* feel free to replace the button with your own */}
      <button
        className="cursor-pointer rounded-md bg-neutral-700 px-3 py-1.5 text-lg font-medium text-neutral-100 active:bg-neutral-600"
        onClick={toggleOpen}
      >
        Click to open
      </button>
 
      <AnimatePresence>
        {open && (
          <motion.ul
            initial="closed"
            animate="open"
            exit="closed"
            variants={{ closed: { scale: 0.85, opacity: 0 }, open: { scale: 1, opacity: 1 } }}
            transition={{ duration: 0.15, ease: easeOut, delay: openingDelay }}
            className="absolute right-0 top-full mt-2 w-max origin-top-right rounded-md bg-neutral-900"
          >
            <div ref={containerRef} className="relative" onMouseLeave={resetHoverRect}>
              {/* hover effect */}
              <AnimatePresence>
                {hoverRect && containerRect && (
                  <motion.div
                    initial="hidden"
                    animate="shown"
                    exit="hidden"
                    variants={{
                      hidden: {
                        x: hoverRect.left - containerRect.left,
                        y: hoverRect.top - containerRect.top + 15,
                        width: hoverRect.width,
                        height: hoverRect.height,
                        opacity: 0,
                      },
                      shown: {
                        x: hoverRect.left - containerRect.left,
                        y: hoverRect.top - containerRect.top,
                        width: hoverRect.width,
                        height: hoverRect.height,
                        opacity: 1,
                      },
                    }}
                    transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                    className="absolute left-0 top-0 h-8 w-16 rounded-md bg-white/10"
                  />
                )}
              </AnimatePresence>
              {links.map((link, i) => {
                return (
                  <motion.li
                    key={link.label}
                    onMouseOver={updateHoverRect}
                    initial={{ opacity: 0, scale: 0.8 }}
                    animate={{ opacity: 1, scale: 1 }}
                    transition={{
                      duration: 0.3,
                      ease: easeOut,
                      delay: i * delay + openingDelay,
                    }}
                  >
                    <a
                      className="relative z-10 block w-full cursor-pointer rounded-lg px-8 py-3.5 text-center text-neutral-200"
                      onClick={toggleOpen}
                      href={link.href}
                    >
                      {link.label}
                    </a>
                  </motion.li>
                );
              })}
            </div>
          </motion.ul>
        )}
      </AnimatePresence>
    </div>
  );
}

Credits

Built by IliyaFaz

Inspired by Chrome's menu opening animation on mobile & Vercel's navbar hover effect.