Overview
Click a card and it grows into a dialog: the image, title and corner button travel with it, and the corner + turns into the close ×. The backdrop blurs in, then the summary, tags, body and link rise in one after another. Closing reverses it, and the card settles back into the grid. While open, focus stays inside the dialog, the page can't scroll behind it, and Esc or a click on the backdrop closes it, returning focus to the card. Hovering a card zooms its image slightly and rotates the +.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/expandable-card.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useEffect, useId, useRef, useState } from "react";4import { createPortal } from "react-dom";5import {6 AnimatePresence,7 LayoutGroup,8 motion,9 useReducedMotion,10} from "motion/react";11import { ArrowUpRight, Plus } from "lucide-react";12import { cn } from "@/lib/utils";13 14export type ExpandableCardItem = {15 id: string;16 title: string;17 summary: string;18 body: React.ReactNode;19 image?: string;20 /** Fallback fill when `image` is omitted. */21 surface?: string;22 /** Short facts shown as chips in the expanded view, e.g. ["2024", "Next.js"]. */23 meta?: string[];24 /** Adds a link button to the expanded view. */25 href?: string;26 hrefLabel?: string;27};28 29export type ExpandableCardProps = {30 items: ExpandableCardItem[];31 className?: string;32 /** Grid columns from the `sm` breakpoint up. */33 columns?: 2 | 3;34 onOpenChange?: (id: string | null) => void;35};36 37const EASE_OUT = [0.23, 1, 0.32, 1] as const;38const RADIUS = 18;39const FOCUSABLE =40 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';41 42function Media({ item, className, zoom }: { item: ExpandableCardItem; className?: string; zoom?: boolean }) {43 return (44 <div className={cn("relative w-full overflow-hidden bg-(--color-paper-2)", className)}>45 {item.image ? (46 // eslint-disable-next-line @next/next/no-img-element47 <img48 src={item.image}49 alt=""50 draggable={false}51 className={cn(52 "size-full object-cover object-top",53 zoom &&54 "transition-transform duration-700 ease-[cubic-bezier(0.23,1,0.32,1)] [@media(hover:hover)]:group-hover:scale-[1.04]",55 )}56 />57 ) : (58 <div59 className="size-full"60 style={{61 background: item.surface ?? "linear-gradient(145deg, var(--color-paper-2), var(--color-rule))",62 }}63 />64 )}65 <div aria-hidden className="pointer-events-none absolute inset-0 shadow-[inset_0_-1px_0_var(--color-rule)]" />66 </div>67 );68}69 70export function ExpandableCard({ items, className, columns = 2, onOpenChange }: ExpandableCardProps) {71 const reduce = !!useReducedMotion();72 const uid = useId();73 const [activeId, setActiveId] = useState<string | null>(null);74 const [mounted, setMounted] = useState(false);75 const active = items.find((i) => i.id === activeId) ?? null;76 const dialogRef = useRef<HTMLDivElement>(null);77 const triggers = useRef(new Map<string, HTMLButtonElement>());78 const lastId = useRef<string | null>(null);79 80 const spring = reduce ? { duration: 0.18 } : { type: "spring" as const, bounce: 0.12, duration: 0.5 };81 const lid = (part: string, id: string) => (reduce ? undefined : `${uid}-${part}-${id}`);82 83 const setOpen = useCallback(84 (id: string | null) => {85 if (id) lastId.current = id;86 setActiveId(id);87 onOpenChange?.(id);88 },89 [onOpenChange],90 );91 92 useEffect(() => setMounted(true), []);93 94 // Escape, focus trap and scroll lock while open; focus returns to the card on close.95 useEffect(() => {96 if (!activeId) {97 const id = lastId.current;98 if (!id) return;99 const t = window.setTimeout(() => triggers.current.get(id)?.focus({ preventScroll: true }), reduce ? 50 : 420);100 return () => window.clearTimeout(t);101 }102 103 const raf = requestAnimationFrame(() =>104 dialogRef.current?.querySelector<HTMLElement>("[data-autofocus]")?.focus({ preventScroll: true }),105 );106 const onKey = (e: KeyboardEvent) => {107 if (e.key === "Escape") {108 e.preventDefault();109 setOpen(null);110 return;111 }112 if (e.key !== "Tab") return;113 const nodes = dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE);114 if (!nodes?.length) return;115 const first = nodes[0];116 const last = nodes[nodes.length - 1];117 if (e.shiftKey && document.activeElement === first) {118 e.preventDefault();119 last.focus();120 } else if (!e.shiftKey && document.activeElement === last) {121 e.preventDefault();122 first.focus();123 }124 };125 const html = document.documentElement;126 const gutter = window.innerWidth - html.clientWidth;127 const prev = { overflow: html.style.overflow, pad: html.style.paddingRight };128 html.style.overflow = "hidden";129 if (gutter > 0) html.style.paddingRight = `${gutter}px`;130 document.addEventListener("keydown", onKey);131 return () => {132 cancelAnimationFrame(raf);133 document.removeEventListener("keydown", onKey);134 html.style.overflow = prev.overflow;135 html.style.paddingRight = prev.pad;136 };137 }, [activeId, reduce, setOpen]);138 139 const reveal = (i: number) =>140 reduce141 ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: 0.08 } } }142 : {143 initial: { opacity: 0, y: 10, filter: "blur(4px)" },144 animate: {145 opacity: 1,146 y: 0,147 filter: "blur(0px)",148 transition: { duration: 0.4, ease: EASE_OUT, delay: 0.12 + i * 0.05 },149 },150 exit: { opacity: 0, filter: "blur(4px)", transition: { duration: 0.1 } },151 };152 153 const overlay =154 mounted &&155 createPortal(156 <AnimatePresence>157 {active ? (158 <div key={active.id} className="fixed inset-0 z-100">159 <motion.div160 aria-hidden161 className="absolute inset-0 bg-black/35 dark:bg-black/55"162 initial={{ opacity: 0, backdropFilter: "blur(0px)" }}163 animate={{ opacity: 1, backdropFilter: reduce ? "blur(0px)" : "blur(8px)" }}164 exit={{ opacity: 0, backdropFilter: "blur(0px)" }}165 transition={{ duration: 0.3, ease: EASE_OUT }}166 onClick={() => setOpen(null)}167 />168 <div className="pointer-events-none absolute inset-0 flex items-end justify-center p-3 sm:items-center sm:p-6">169 <motion.div170 ref={dialogRef}171 role="dialog"172 aria-modal="true"173 aria-labelledby={`${uid}-title-${active.id}`}174 layoutId={lid("card", active.id)}175 transition={spring}176 initial={reduce ? { opacity: 0 } : undefined}177 animate={reduce ? { opacity: 1 } : undefined}178 exit={reduce ? { opacity: 0 } : undefined}179 style={{ borderRadius: RADIUS }}180 className={cn(181 "pointer-events-auto relative flex max-h-[min(88dvh,640px)] w-full max-w-lg flex-col overflow-hidden",182 "bg-(--color-paper) shadow-[inset_0_0_0_1px_var(--color-rule),0_24px_64px_-12px_rgba(0,0,0,0.35)]",183 )}184 >185 <motion.div layoutId={lid("media", active.id)} transition={spring} className="shrink-0">186 <Media item={active} className="aspect-16/9" />187 </motion.div>188 189 <motion.button190 type="button"191 data-autofocus192 aria-label="Close"193 onClick={() => setOpen(null)}194 layoutId={lid("icon", active.id)}195 transition={spring}196 style={{ borderRadius: 999 }}197 className={cn(198 "absolute top-3 right-3 z-10 flex size-9 items-center justify-center outline-none",199 "bg-(--color-paper)/80 text-(--color-ink) shadow-[0_0_0_1px_var(--color-rule),0_2px_8px_rgba(0,0,0,0.12)] backdrop-blur-md",200 "transition-[background-color,transform] duration-150 hover:bg-(--color-paper) active:scale-[0.92]",201 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",202 )}203 >204 <motion.span205 initial={{ rotate: reduce ? 45 : 0 }}206 animate={{ rotate: 45 }}207 transition={spring}208 className="flex"209 >210 <Plus className="size-4" strokeWidth={2} />211 </motion.span>212 </motion.button>213 214 <div className="flex min-h-0 flex-col overflow-y-auto overscroll-contain px-5 pt-4 pb-5 sm:px-6 sm:pb-6">215 <motion.div216 id={`${uid}-title-${active.id}`}217 layoutId={lid("title", active.id)}218 transition={spring}219 className="w-fit font-(family-name:--font-display) text-[19px] font-semibold tracking-[-0.02em] text-(--color-ink)"220 >221 {active.title}222 </motion.div>223 <motion.div {...reveal(0)} className="mt-1 text-[13.5px] text-(--color-neutral)">224 {active.summary}225 </motion.div>226 {active.meta?.length ? (227 <motion.div {...reveal(1)} className="mt-3 flex flex-wrap gap-1.5">228 {active.meta.map((m) => (229 <span230 key={m}231 className="inline-flex h-6 items-center rounded-full bg-(--color-paper-2) px-2.5 text-[12px] text-(--color-ink-2)"232 >233 {m}234 </span>235 ))}236 </motion.div>237 ) : null}238 <motion.div {...reveal(2)} className="mt-4 text-[14px] leading-relaxed text-pretty text-(--color-ink-2)">239 {active.body}240 </motion.div>241 {active.href ? (242 <motion.div {...reveal(3)} className="mt-5">243 <a244 href={active.href}245 target="_blank"246 rel="noreferrer"247 className={cn(248 "inline-flex h-9 items-center gap-1.5 rounded-full bg-(--color-ink) px-4 text-[13.5px] font-medium text-(--color-paper) outline-none",249 "transition-transform duration-150 active:scale-[0.96]",250 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",251 )}252 >253 {active.hrefLabel ?? "Visit"}254 <ArrowUpRight className="size-3.5" strokeWidth={2} aria-hidden />255 </a>256 </motion.div>257 ) : null}258 </div>259 </motion.div>260 </div>261 </div>262 ) : null}263 </AnimatePresence>,264 document.body,265 );266 267 return (268 <LayoutGroup id={uid}>269 <div270 role="list"271 className={cn(272 "grid w-full grid-cols-2 gap-2.5 sm:gap-3",273 columns === 3 && "sm:grid-cols-3",274 className,275 )}276 >277 {items.map((item) => {278 const open = activeId === item.id;279 return (280 <div role="listitem" key={item.id} className="min-w-0">281 <motion.button282 ref={(el) => {283 if (el) triggers.current.set(item.id, el);284 else triggers.current.delete(item.id);285 }}286 type="button"287 aria-haspopup="dialog"288 aria-expanded={open}289 onClick={() => setOpen(item.id)}290 layoutId={open ? undefined : lid("card", item.id)}291 transition={spring}292 whileTap={reduce ? undefined : { scale: 0.97 }}293 style={{ borderRadius: RADIUS }}294 className={cn(295 "group relative flex w-full flex-col overflow-hidden bg-(--color-paper) text-left outline-none",296 "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04)]",297 "transition-shadow duration-300 [@media(hover:hover)]:hover:shadow-[inset_0_0_0_1px_var(--color-rule),0_10px_28px_-10px_rgba(0,0,0,0.22)]",298 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",299 open && "invisible",300 )}301 >302 <motion.div layoutId={open ? undefined : lid("media", item.id)} transition={spring}>303 <Media item={item} zoom className="aspect-16/10" />304 </motion.div>305 <motion.span306 aria-hidden307 layoutId={open ? undefined : lid("icon", item.id)}308 transition={spring}309 style={{ borderRadius: 999 }}310 className={cn(311 "absolute top-2 right-2 flex size-7 items-center justify-center",312 "bg-(--color-paper)/80 text-(--color-ink) shadow-[0_0_0_1px_var(--color-rule)] backdrop-blur-md",313 "transition-transform duration-300 [@media(hover:hover)]:group-hover:rotate-90",314 )}315 >316 <Plus className="size-3.5" strokeWidth={2} />317 </motion.span>318 <div className="flex min-w-0 flex-col gap-0.5 p-3 sm:p-3.5">319 <motion.span320 layoutId={open ? undefined : lid("title", item.id)}321 transition={spring}322 className="w-fit max-w-full truncate font-(family-name:--font-display) text-[13.5px] font-semibold tracking-[-0.01em] text-(--color-ink) sm:text-[14px]"323 >324 {item.title}325 </motion.span>326 <span className="line-clamp-2 text-[12px] leading-snug text-pretty text-(--color-neutral)">327 {item.summary}328 </span>329 </div>330 </motion.button>331 </div>332 );333 })}334 </div>335 {overlay}336 </LayoutGroup>337 );338}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { ExpandableCard } from "@/components/ui/expandable-card";2 3<ExpandableCard4 columns={2}5 items={[6 {7 id: "woxly",8 title: "Woxly",9 summary: "Online stores for small sellers.",10 meta: ["Commerce", "Next.js", "2025"],11 body: "Storefronts, orders and payments in one place.",12 image: "/images/landing-woxly.webp",13 href: "https://woxly.store",14 hrefLabel: "Visit Woxly",15 },16 ]}17/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | ExpandableCardItem[] | Yes | — | Cards with id, title, summary, body (text or JSX), and optional image, surface, meta chips, href and hrefLabel. |
| columns | 2 | 3 | No | 2 | Grid columns from the sm breakpoint up. Phones always use two. |
| onOpenChange | (id: string | null) => void | No | — | Fires when a card opens or the dialog closes. |
| className | string | No | — | Class names on the grid. |
Best Practices
- Keep
summaryto one line; it's clamped on the card and repeated under the title in the dialog. - Use landscape images. They're cropped from the top, so put the important part there.
- Long bodies scroll inside the dialog, which is capped at 88% of the viewport height.
- Reduced motion swaps the grow animation for a fade.