Usage
import { ExpandCards } from "@/components/motionkit/expand-card";
<ExpandCards
items={[
{ id: "atlas", title: "Atlas", subtitle: "Ecommerce", content: <p>Case study…</p> },
{ id: "ledger", title: "Ledger", subtitle: "Custom CMS", color: "#A56D5B", content: <p>Case study…</p> },
]}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Morph
Card opened or closed
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/expand-card.tsx.
"use client";
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type ExpandItem = {
id: string;
title: string;
subtitle?: string;
/** Colour for the card's art panel. */
color?: string;
/** Shown only in the expanded view. */
content: ReactNode;
};
type ExpandCardsProps = {
items: ExpandItem[];
className?: string;
};
const noop = () => () => {};
const useIsClient = () => useSyncExternalStore(noop, () => true, () => false);
export function ExpandCards({ items, className = "" }: ExpandCardsProps) {
const [selected, setSelected] = useState<string | null>(null);
const reduceMotion = useReducedMotion();
const isClient = useIsClient();
const group = useId();
const triggers = useRef<Record<string, HTMLButtonElement | null>>({});
const openItem = items.find((item) => item.id === selected);
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, bounce: 0.15, duration: 0.45 };
useEffect(() => {
if (!selected) return;
const onKey = (event: KeyboardEvent) => event.key === "Escape" && close();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
function close() {
const id = selected;
setSelected(null);
// give focus back to the card that opened the dialog
if (id) window.requestAnimationFrame(() => triggers.current[id]?.focus());
}
return (
<>
<ul className={`grid gap-3 sm:grid-cols-2 ${className}`}>
{items.map((item) => (
<li key={item.id}>
{selected === item.id ? (
// keeps the grid from collapsing while the card is out
<div aria-hidden className="h-full min-h-40 rounded-2xl border border-dashed border-white/10" />
) : (
<motion.button
ref={(element) => {
triggers.current[item.id] = element;
}}
layoutId={`${group}-${item.id}`}
transition={transition}
onClick={() => setSelected(item.id)}
aria-haspopup="dialog"
className="block w-full overflow-hidden rounded-2xl border border-white/10 bg-[#141B1B] text-left text-[#F6F1E8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5FAEB4]"
>
<motion.div layoutId={`${group}-${item.id}-art`} transition={transition} className="h-20" style={{ background: art(item.color) }} />
<motion.div layoutId={`${group}-${item.id}-text`} transition={transition} className="p-4">
<p className="text-sm font-medium">{item.title}</p>
{item.subtitle && <p className="mt-1 text-xs text-[#94A9A7]">{item.subtitle}</p>}
</motion.div>
</motion.button>
)}
</li>
))}
</ul>
{isClient &&
createPortal(
<AnimatePresence>
{openItem && (
<motion.div
key="overlay"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={(event) => event.target === event.currentTarget && close()}
>
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby={`${group}-title`}
layoutId={`${group}-${openItem.id}`}
transition={transition}
className="w-full max-w-md overflow-hidden rounded-2xl border border-white/10 bg-[#141B1B] text-[#F6F1E8] shadow-2xl shadow-black/60"
>
<motion.div layoutId={`${group}-${openItem.id}-art`} transition={transition} className="h-36" style={{ background: art(openItem.color) }} />
<motion.div layoutId={`${group}-${openItem.id}-text`} transition={transition} className="p-5">
<p id={`${group}-title`} className="text-lg font-medium">
{openItem.title}
</p>
{openItem.subtitle && <p className="mt-1 text-sm text-[#94A9A7]">{openItem.subtitle}</p>}
</motion.div>
<motion.div
className="px-5 pb-5 text-sm leading-6 text-[#DCE3E2]"
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: reduceMotion ? 0 : 0.15 } }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
>
{openItem.content}
<div className="mt-5 flex justify-end">
<button
autoFocus
onClick={close}
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs text-[#DCE3E2] transition-colors hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-[#5FAEB4]"
>
Close
</button>
</div>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}
const art = (color = "#0E7C86") =>
`radial-gradient(120% 120% at 0% 0%, ${color}, transparent 70%), radial-gradient(100% 100% at 100% 100%, #A56D5B66, transparent 60%), #0C1010`;
| Prop | Type | Default | Description |
|---|---|---|---|
| items | ExpandItem[] | — | { id, title, subtitle?, color?, content } |
Realtime sync
Move your cursor across this card.
Spotlight Card
A card with a soft glow that tracks the pointer across its surface.
Edge functions
Tilt me with your cursor.
Tilt Card
A 3D card that tilts toward the pointer, with an optional moving glare.
Launch kit
Tilt me to see the layers.
Depth Card
A card that tilts toward the cursor while its layers float at different depths, so each moves at its own speed.