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 useEffect(() => {131 if (activeId) {132 const t = requestAnimationFrame(() => closeBtnRef.current?.focus());133 return () => cancelAnimationFrame(t);134 }135 triggerRef.current?.focus?.();136 }, [activeId]);137 138 useEffect(() => {139 if (!activeId || !dialogRef.current) return;140 const root = dialogRef.current;141 142 const onKeyDown = (e: KeyboardEvent) => {143 if (e.key !== "Tab") return;144 const nodes = root.querySelectorAll<HTMLElement>(145 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',146 );147 if (nodes.length === 0) return;148 const first = nodes[0];149 const last = nodes[nodes.length - 1];150 if (e.shiftKey && document.activeElement === first) {151 e.preventDefault();152 last.focus();153 } else if (!e.shiftKey && document.activeElement === last) {154 e.preventDefault();155 first.focus();156 }157 };158 159 root.addEventListener("keydown", onKeyDown);160 return () => root.removeEventListener("keydown", onKeyDown);161 }, [activeId]);162 163 const overlay =164 mounted &&165 createPortal(166 <AnimatePresence initial={false} mode="sync">167 {active ? (168 <>169 <motion.button170 key={`scrim-${active.id}`}171 type="button"172 aria-label="Close"173 className="fixed inset-0 z-100 bg-black/40 backdrop-blur-[2px]"174 initial={{ opacity: 0 }}175 animate={{ opacity: 1 }}176 exit={{ opacity: 0 }}177 transition={fade}178 onClick={close}179 />180 <motion.div181 key={`stage-${active.id}`}182 className="pointer-events-none fixed inset-0 z-110 flex items-end justify-center p-3 sm:items-center sm:p-6"183 initial={false}184 animate={{ opacity: 1 }}185 exit={{186 opacity: 1,187 transition: reduce ? { duration: 0.12 } : { duration: 0.38 },188 }}189 >190 <motion.article191 ref={dialogRef}192 layoutId={reduce ? undefined : `card-${active.id}`}193 role="dialog"194 aria-modal="true"195 aria-labelledby={`expand-title-${active.id}`}196 transition={spring}197 initial={198 reduce199 ? { opacity: 0, scale: 0.97 }200 : false201 }202 animate={203 reduce204 ? { opacity: 1, scale: 1 }205 : undefined206 }207 exit={208 reduce209 ? {210 opacity: 0,211 scale: 0.97,212 transition: { duration: 0.12 },213 }214 : undefined215 }216 className={cn(217 "t-resize pointer-events-auto relative flex w-full max-w-md flex-col overflow-hidden",218 "rounded-t-[calc(var(--radius-lg)+6px)] border border-(--color-rule) bg-(--color-paper) sm:rounded-[calc(var(--radius-lg)+4px)]",219 "shadow-[0_8px_24px_rgba(0,0,0,0.08),0_28px_64px_rgba(0,0,0,0.18)]",220 "max-h-[min(85dvh,560px)]",221 )}222 >223 <CardMedia224 item={active}225 layoutId={reduce ? undefined : `surface-${active.id}`}226 transition={spring}227 className="aspect-16/10 h-auto shrink-0"228 />229 230 <button231 ref={closeBtnRef}232 type="button"233 onClick={close}234 aria-label="Close"235 className={cn(236 "absolute top-3 right-3 z-20 flex size-10 items-center justify-center rounded-full",237 "border border-(--color-rule) bg-(--color-paper)/90 text-(--color-ink) shadow-sm backdrop-blur-md",238 "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96]",239 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",240 )}241 >242 <X className="size-4" strokeWidth={2} />243 </button>244 245 <div className="flex min-h-0 flex-col gap-2.5 overflow-y-auto p-5 pt-4">246 <motion.h2247 id={`expand-title-${active.id}`}248 layoutId={reduce ? undefined : `title-${active.id}`}249 className="pr-10 font-(family-name:--font-display) text-base font-medium tracking-[-0.02em] text-balance text-(--color-ink) sm:text-lg"250 transition={spring}251 >252 {active.title}253 </motion.h2>254 255 <p className="text-sm leading-relaxed text-pretty text-(--color-ink-2)">256 {active.body}257 </p>258 259 <button260 type="button"261 onClick={close}262 className={cn(263 "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)",264 "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96]",265 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",266 )}267 >268 Close269 </button>270 </div>271 </motion.article>272 </motion.div>273 </>274 ) : null}275 </AnimatePresence>,276 document.body,277 );278 279 return (280 <LayoutGroup id={layoutGroupId}>281 <div className={cn("w-full", className)}>282 <div className="grid w-full grid-cols-2 gap-2.5 sm:gap-3">283 {items.map((item) => {284 const selected = activeId === item.id;285 return (286 <motion.button287 key={item.id}288 type="button"289 layoutId={reduce || selected ? undefined : `card-${item.id}`}290 transition={spring}291 whileTap={reduce ? undefined : { scale: 0.96 }}292 onClick={(e) => open(item.id, e.currentTarget)}293 className={cn(294 "t-resize flex min-h-11 flex-col overflow-hidden rounded-md border border-(--color-rule) bg-(--color-paper) text-left sm:rounded-lg",295 "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.06)]",296 "transition-shadow duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",297 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",298 "dark:shadow-[0_8px_28px_rgba(0,0,0,0.35)]",299 "[@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)]",300 selected && "invisible pointer-events-none",301 )}302 >303 <CardMedia304 item={item}305 layoutId={306 reduce || selected ? undefined : `surface-${item.id}`307 }308 transition={spring}309 className="aspect-16/10 h-auto"310 />311 <div className="flex flex-col gap-0.5 p-2.5 sm:gap-1 sm:p-3.5">312 <motion.h3313 layoutId={314 reduce || selected ? undefined : `title-${item.id}`315 }316 transition={spring}317 className="truncate font-(family-name:--font-display) text-[13px] font-medium tracking-tight text-(--color-ink) sm:text-sm"318 >319 {item.title}320 </motion.h3>321 <p className="line-clamp-2 text-[11px] leading-snug text-pretty text-(--color-ink-2) sm:text-xs sm:leading-relaxed">322 {item.summary}323 </p>324 </div>325 </motion.button>326 );327 })}328 </div>329 {overlay}330 </div>331 </LayoutGroup>332 );333}
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.