Overview
Cards expand into a modal detail via shared layoutId on the shell, surface, and title. The overlay materializes with blur; dismiss reverses the same path (Escape or scrim).
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, {4 useCallback,5 useEffect,6 useId,7 useRef,8 useState,9} from "react";10import { createPortal } from "react-dom";11import {12 AnimatePresence,13 motion,14 useReducedMotion,15 LayoutGroup,16} from "motion/react";17import { X } from "lucide-react";18import { cn } from "@/lib/utils";19 20export type ExpandableCardItem = {21 id: string;22 title: string;23 summary: string;24 body: string;25 image?: string;26 /** Fallback fill when `image` is omitted */27 surface?: string;28};29 30export type ExpandableCardProps = {31 items: ExpandableCardItem[];32 className?: string;33};34 35const EASE_OUT = [0.23, 1, 0.32, 1] as const;36 37function CardMedia({38 item,39 layoutId,40 className,41 transition,42}: {43 item: ExpandableCardItem;44 layoutId?: string;45 className?: string;46 transition: object;47}) {48 return (49 <motion.div50 layoutId={layoutId}51 transition={transition}52 className={cn(53 "relative w-full overflow-hidden bg-(--color-paper-2)",54 "outline outline-1 outline-black/10 dark:outline-white/10",55 className,56 )}57 >58 {item.image ? (59 // eslint-disable-next-line @next/next/no-img-element60 <img61 src={item.image}62 alt=""63 draggable={false}64 className="h-full w-full object-cover object-top"65 />66 ) : (67 <div68 className="h-full w-full"69 style={{70 background:71 item.surface ??72 "linear-gradient(145deg, var(--color-paper-2), var(--color-rule))",73 }}74 />75 )}76 </motion.div>77 );78}79 80export function ExpandableCard({ items, className }: ExpandableCardProps) {81 const reduce = useReducedMotion();82 const [activeId, setActiveId] = useState<string | null>(null);83 const [mounted, setMounted] = useState(false);84 const active = items.find((i) => i.id === activeId) ?? null;85 const layoutGroupId = useId();86 const dialogRef = useRef<HTMLElement>(null);87 const triggerRef = useRef<HTMLElement | null>(null);88 const closeBtnRef = useRef<HTMLButtonElement>(null);89 90 const spring = reduce91 ? { duration: 0.15 }92 : { type: "spring" as const, bounce: 0, duration: 0.38 };93 94 const fade = reduce95 ? { duration: 0.12 }96 : { duration: 0.2, ease: EASE_OUT };97 98 const close = useCallback(() => {99 setActiveId(null);100 }, []);101 102 const open = useCallback((id: string, el: HTMLElement) => {103 triggerRef.current = el;104 setActiveId(id);105 }, []);106 107 useEffect(() => setMounted(true), []);108 109 useEffect(() => {110 if (!activeId) return;111 const onKey = (e: KeyboardEvent) => {112 if (e.key === "Escape") {113 e.preventDefault();114 close();115 }116 };117 window.addEventListener("keydown", onKey);118 return () => window.removeEventListener("keydown", onKey);119 }, [activeId, close]);120 121 useEffect(() => {122 if (!activeId) return;123 const prev = document.body.style.overflow;124 document.body.style.overflow = "hidden";125 return () => {126 document.body.style.overflow = prev;127 };128 }, [activeId]);129 130 // Focus close control on open; restore trigger on close131 useEffect(() => {132 if (activeId) {133 const t = requestAnimationFrame(() => closeBtnRef.current?.focus());134 return () => cancelAnimationFrame(t);135 }136 triggerRef.current?.focus?.();137 }, [activeId]);138 139 // Lightweight focus trap inside the dialog140 useEffect(() => {141 if (!activeId || !dialogRef.current) return;142 const root = dialogRef.current;143 144 const onKeyDown = (e: KeyboardEvent) => {145 if (e.key !== "Tab") return;146 const nodes = root.querySelectorAll<HTMLElement>(147 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',148 );149 if (nodes.length === 0) return;150 const first = nodes[0];151 const last = nodes[nodes.length - 1];152 if (e.shiftKey && document.activeElement === first) {153 e.preventDefault();154 last.focus();155 } else if (!e.shiftKey && document.activeElement === last) {156 e.preventDefault();157 first.focus();158 }159 };160 161 root.addEventListener("keydown", onKeyDown);162 return () => root.removeEventListener("keydown", onKeyDown);163 }, [activeId]);164 165 const overlay =166 mounted &&167 createPortal(168 <AnimatePresence initial={false} mode="sync">169 {active ? (170 <>171 <motion.button172 key={`scrim-${active.id}`}173 type="button"174 aria-label="Close"175 className="fixed inset-0 z-100 bg-black/40 backdrop-blur-[2px]"176 initial={{ opacity: 0 }}177 animate={{ opacity: 1 }}178 exit={{ opacity: 0 }}179 transition={fade}180 onClick={close}181 />182 <motion.div183 key={`stage-${active.id}`}184 className="pointer-events-none fixed inset-0 z-110 flex items-end justify-center p-3 sm:items-center sm:p-6"185 initial={false}186 animate={{ opacity: 1 }}187 // Keep the stage mounted until the layout morph finishes188 exit={{189 opacity: 1,190 transition: reduce ? { duration: 0.12 } : { duration: 0.38 },191 }}192 >193 <motion.article194 ref={dialogRef}195 layoutId={reduce ? undefined : `card-${active.id}`}196 role="dialog"197 aria-modal="true"198 aria-labelledby={`expand-title-${active.id}`}199 transition={spring}200 initial={201 reduce202 ? { opacity: 0, scale: 0.97, filter: "blur(2px)" }203 : { filter: "blur(2px)" }204 }205 animate={206 reduce207 ? { opacity: 1, scale: 1, filter: "blur(0px)" }208 : { filter: "blur(0px)" }209 }210 exit={211 reduce212 ? {213 opacity: 0,214 scale: 0.97,215 filter: "blur(2px)",216 transition: { duration: 0.12 },217 }218 : {219 filter: "blur(2px)",220 transition: { duration: 0.16, ease: EASE_OUT },221 }222 }223 className={cn(224 "pointer-events-auto relative flex w-full max-w-md flex-col overflow-hidden",225 // Outer radius larger than media so the morph reads as one surface226 "rounded-t-[calc(var(--radius-lg)+6px)] border border-(--color-rule) bg-(--color-paper) sm:rounded-[calc(var(--radius-lg)+4px)]",227 "shadow-[0_8px_24px_rgba(0,0,0,0.08),0_28px_64px_rgba(0,0,0,0.18)]",228 "max-h-[min(85dvh,560px)]",229 )}230 >231 <CardMedia232 item={active}233 layoutId={reduce ? undefined : `surface-${active.id}`}234 transition={spring}235 className="aspect-16/10 h-auto shrink-0"236 />237 238 <button239 ref={closeBtnRef}240 type="button"241 onClick={close}242 aria-label="Close"243 className={cn(244 "absolute top-3 right-3 z-20 flex size-10 items-center justify-center rounded-full",245 "border border-(--color-rule) bg-(--color-paper)/90 text-(--color-ink) shadow-sm backdrop-blur-md",246 "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96]",247 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",248 )}249 >250 <X className="size-4" strokeWidth={2} />251 </button>252 253 <div className="flex min-h-0 flex-col gap-2.5 overflow-y-auto p-5 pt-4">254 <motion.h2255 id={`expand-title-${active.id}`}256 layoutId={reduce ? undefined : `title-${active.id}`}257 className="pr-10 font-(family-name:--font-display) text-base font-medium tracking-[-0.02em] text-balance text-(--color-ink) sm:text-lg"258 transition={spring}259 >260 {active.title}261 </motion.h2>262 263 <motion.p264 initial={reduce ? false : { opacity: 0, y: 8 }}265 animate={{ opacity: 1, y: 0 }}266 exit={{ opacity: 0, y: -8 }}267 transition={268 reduce269 ? { duration: 0.1 }270 : { duration: 0.2, ease: EASE_OUT, delay: 0.05 }271 }272 className="text-sm leading-relaxed text-pretty text-(--color-ink-2)"273 >274 {active.body}275 </motion.p>276 277 <motion.button278 type="button"279 onClick={close}280 initial={reduce ? false : { opacity: 0, y: 8 }}281 animate={{ opacity: 1, y: 0 }}282 exit={{ opacity: 0, y: -6 }}283 transition={284 reduce285 ? { duration: 0.1 }286 : { duration: 0.2, ease: EASE_OUT, delay: 0.1 }287 }288 className={cn(289 "mt-1 min-h-10 self-start rounded-sm border border-(--color-rule) bg-(--color-paper) px-4 text-[13px] font-medium text-(--color-ink)",290 "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96]",291 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",292 )}293 >294 Close295 </motion.button>296 </div>297 </motion.article>298 </motion.div>299 </>300 ) : null}301 </AnimatePresence>,302 document.body,303 );304 305 return (306 <LayoutGroup id={layoutGroupId}>307 <div className={cn("w-full", className)}>308 <div className="grid w-full grid-cols-2 gap-2.5 sm:gap-3">309 {items.map((item) => {310 const selected = activeId === item.id;311 return (312 <motion.button313 key={item.id}314 type="button"315 layoutId={reduce || selected ? undefined : `card-${item.id}`}316 transition={spring}317 whileTap={reduce ? undefined : { scale: 0.96 }}318 onClick={(e) => open(item.id, e.currentTarget)}319 className={cn(320 "flex min-h-11 flex-col overflow-hidden rounded-md border border-(--color-rule) bg-(--color-paper) text-left sm:rounded-lg",321 "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.06)]",322 "transition-shadow duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",323 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",324 "dark:shadow-[0_8px_28px_rgba(0,0,0,0.35)]",325 "[@media(hover:hover)_and_(pointer:fine)]:hover:shadow-[0_2px_4px_rgba(0,0,0,0.05),0_12px_32px_rgba(0,0,0,0.1)]",326 selected && "invisible pointer-events-none",327 )}328 >329 <CardMedia330 item={item}331 layoutId={332 reduce || selected ? undefined : `surface-${item.id}`333 }334 transition={spring}335 className="aspect-16/10 h-auto"336 />337 <div className="flex flex-col gap-0.5 p-2.5 sm:gap-1 sm:p-3.5">338 <motion.h3339 layoutId={340 reduce || selected ? undefined : `title-${item.id}`341 }342 transition={spring}343 className="truncate font-(family-name:--font-display) text-[13px] font-medium tracking-tight text-(--color-ink) sm:text-sm"344 >345 {item.title}346 </motion.h3>347 <p className="line-clamp-2 text-[11px] leading-snug text-pretty text-(--color-ink-2) sm:text-xs sm:leading-relaxed">348 {item.summary}349 </p>350 </div>351 </motion.button>352 );353 })}354 </div>355 {overlay}356 </div>357 </LayoutGroup>358 );359}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { ExpandableCard } from "@/components/ui/expandable-card";2 3<ExpandableCard4 items={[5 {6 id: "woxly",7 title: "Woxly",8 summary: "Multi-tenant commerce storefronts.",9 body: "Storefronts, vendors, and payments in one commerce stack.",10 image: "/images/landing-woxly.png",11 },12 ]}13/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | ExpandableCardItem[] | Yes | — | Cards with id, title, summary, body, image URL, and optional surface fallback. |
| className | string | No | — | Optional class names on the root. |
Best Practices
- Share layout ids for the elements that should feel continuous (shell, media, title).
- Materialize overlays with blur + scale — not opacity alone.
- Press scale
0.96on grid tiles for instant down feedback. - Under reduced motion, drop layout springs and crossfade with a short opacity transition.