Expandable Cards
Cards that grow seamlessly into a full dialog using shared layout animation.
Click a card to expand it
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>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/expandable-cards.tsxcomponents/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> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.