Expandable Cards

Cards that grow seamlessly into a full dialog using shared layout animation.

Click a card to expand it

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/expandable-cards.tsx

    components/ui/expandable-cards.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { X } from "lucide-react";
    import { useEffect, useId, useState } from "react";
    
    type CardItem = { id: string; title: string; subtitle: string; body: string; color: string };
    
    const defaultItems: CardItem[] = [
      { id: "aurora", title: "Aurora", subtitle: "Brand identity", color: "from-sky-300 via-blue-400 to-indigo-500", body: "A calm, luminous identity for a climate-tech startup. We built a flexible gradient system that adapts from tiny app icons to 20-metre billboards." },
      { id: "ember", title: "Ember", subtitle: "Product design", color: "from-amber-300 via-orange-400 to-rose-500", body: "A cooking companion app that turns recipes into step-by-step guidance. Usability tests showed a 40% drop in abandoned recipes." },
      { id: "fern", title: "Fern", subtitle: "Marketing site", color: "from-emerald-300 via-teal-400 to-cyan-500", body: "A fast, accessible marketing site for a wellness brand, with scroll-driven storytelling and a 98 Lighthouse score." },
    ];
    
    export function ExpandableCards({ items = defaultItems }: { items?: CardItem[] }) {
      const [active, setActive] = useState<CardItem | null>(null);
      const uid = useId();
    
      useEffect(() => {
        const onKey = (e: KeyboardEvent) => e.key === "Escape" && setActive(null);
        window.addEventListener("keydown", onKey);
        return () => window.removeEventListener("keydown", onKey);
      }, []);
    
      return (
        <>
          <AnimatePresence>
            {active && (
              <motion.div
                key="overlay"
                className="fixed inset-0 z-[100] bg-zinc-900/30 backdrop-blur-sm"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                onClick={() => setActive(null)}
              />
            )}
          </AnimatePresence>
          <AnimatePresence>
            {active && (
              <div className="pointer-events-none fixed inset-0 z-[101] grid place-items-center p-4">
                <motion.div
                  layoutId={`card-${active.id}-${uid}`}
                  role="dialog"
                  aria-modal="true"
                  aria-label={active.title}
                  className="pointer-events-auto relative w-full max-w-lg overflow-hidden rounded-3xl bg-white shadow-2xl"
                >
                  <motion.div layoutId={`img-${active.id}-${uid}`} className={`h-56 bg-gradient-to-br ${active.color}`} />
                  <div className="p-7">
                    <motion.p layoutId={`sub-${active.id}-${uid}`} className="text-sm text-zinc-500">{active.subtitle}</motion.p>
                    <motion.h3 layoutId={`title-${active.id}-${uid}`} className="text-2xl font-semibold tracking-tight text-zinc-900">{active.title}</motion.h3>
                    <motion.p initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0, transition: { delay: 0.15 } }} exit={{ opacity: 0 }} className="mt-4 leading-relaxed text-zinc-600">
                      {active.body}
                    </motion.p>
                  </div>
                  <button
                    onClick={() => setActive(null)}
                    aria-label="Close"
                    className="absolute right-4 top-4 grid size-9 place-items-center rounded-full bg-white/90 text-zinc-700 shadow-sm backdrop-blur hover:bg-white"
                  >
                    <X className="size-4" />
                  </button>
                </motion.div>
              </div>
            )}
          </AnimatePresence>
          <ul className="grid w-full gap-4 sm:grid-cols-3">
            {items.map((item) => (
              <li key={item.id}>
                <motion.button
                  layoutId={`card-${item.id}-${uid}`}
                  onClick={() => setActive(item)}
                  className="w-full overflow-hidden rounded-3xl border border-zinc-200 bg-white text-left shadow-sm transition-shadow hover:shadow-lg"
                >
                  <motion.div layoutId={`img-${item.id}-${uid}`} className={`h-36 bg-gradient-to-br ${item.color}`} />
                  <div className="p-5">
                    <motion.p layoutId={`sub-${item.id}-${uid}`} className="text-sm text-zinc-500">{item.subtitle}</motion.p>
                    <motion.h3 layoutId={`title-${item.id}-${uid}`} className="text-lg font-semibold text-zinc-900">{item.title}</motion.h3>
                  </div>
                </motion.button>
              </li>
            ))}
          </ul>
        </>
      );
    }
    
    export default function Demo() {
      return (
        <div className="w-full max-w-3xl">
          <ExpandableCards />
          <p className="mt-4 text-center text-xs text-zinc-400">Click a card to expand it</p>
        </div>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.