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.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/expandable-card.tsx
tsx
"use client";
import React, {  useCallback,  useEffect,  useId,  useRef,  useState,} from "react";import { createPortal } from "react-dom";import {  AnimatePresence,  motion,  useReducedMotion,  LayoutGroup,} from "motion/react";import { X } from "lucide-react";import { cn } from "@/lib/utils";
export type ExpandableCardItem = {  id: string;  title: string;  summary: string;  body: string;  image?: string;  /** Fallback fill when `image` is omitted */  surface?: string;};
export type ExpandableCardProps = {  items: ExpandableCardItem[];  className?: string;};
const EASE_OUT = [0.23, 1, 0.32, 1] as const;
function CardMedia({  item,  layoutId,  className,  transition,}: {  item: ExpandableCardItem;  layoutId?: string;  className?: string;  transition: object;}) {  return (    <motion.div      layoutId={layoutId}      transition={transition}      className={cn(        "relative w-full overflow-hidden bg-(--color-paper-2)",        "outline outline-1 outline-black/10 dark:outline-white/10",        className,      )}    >      {item.image ? (        // eslint-disable-next-line @next/next/no-img-element        <img          src={item.image}          alt=""          draggable={false}          className="h-full w-full object-cover object-top"        />      ) : (        <div          className="h-full w-full"          style={{            background:              item.surface ??              "linear-gradient(145deg, var(--color-paper-2), var(--color-rule))",          }}        />      )}    </motion.div>  );}
export function ExpandableCard({ items, className }: ExpandableCardProps) {  const reduce = useReducedMotion();  const [activeId, setActiveId] = useState<string | null>(null);  const [mounted, setMounted] = useState(false);  const active = items.find((i) => i.id === activeId) ?? null;  const layoutGroupId = useId();  const dialogRef = useRef<HTMLElement>(null);  const triggerRef = useRef<HTMLElement | null>(null);  const closeBtnRef = useRef<HTMLButtonElement>(null);
  const spring = reduce    ? { duration: 0.15 }    : { type: "spring" as const, bounce: 0, duration: 0.38 };
  const fade = reduce    ? { duration: 0.12 }    : { duration: 0.2, ease: EASE_OUT };
  const close = useCallback(() => {    setActiveId(null);  }, []);
  const open = useCallback((id: string, el: HTMLElement) => {    triggerRef.current = el;    setActiveId(id);  }, []);
  useEffect(() => setMounted(true), []);
  useEffect(() => {    if (!activeId) return;    const onKey = (e: KeyboardEvent) => {      if (e.key === "Escape") {        e.preventDefault();        close();      }    };    window.addEventListener("keydown", onKey);    return () => window.removeEventListener("keydown", onKey);  }, [activeId, close]);
  useEffect(() => {    if (!activeId) return;    const prev = document.body.style.overflow;    document.body.style.overflow = "hidden";    return () => {      document.body.style.overflow = prev;    };  }, [activeId]);
  // Focus close control on open; restore trigger on close  useEffect(() => {    if (activeId) {      const t = requestAnimationFrame(() => closeBtnRef.current?.focus());      return () => cancelAnimationFrame(t);    }    triggerRef.current?.focus?.();  }, [activeId]);
  // Lightweight focus trap inside the dialog  useEffect(() => {    if (!activeId || !dialogRef.current) return;    const root = dialogRef.current;
    const onKeyDown = (e: KeyboardEvent) => {      if (e.key !== "Tab") return;      const nodes = root.querySelectorAll<HTMLElement>(        'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',      );      if (nodes.length === 0) return;      const first = nodes[0];      const last = nodes[nodes.length - 1];      if (e.shiftKey && document.activeElement === first) {        e.preventDefault();        last.focus();      } else if (!e.shiftKey && document.activeElement === last) {        e.preventDefault();        first.focus();      }    };
    root.addEventListener("keydown", onKeyDown);    return () => root.removeEventListener("keydown", onKeyDown);  }, [activeId]);
  const overlay =    mounted &&    createPortal(      <AnimatePresence initial={false} mode="sync">        {active ? (          <>            <motion.button              key={`scrim-${active.id}`}              type="button"              aria-label="Close"              className="fixed inset-0 z-100 bg-black/40 backdrop-blur-[2px]"              initial={{ opacity: 0 }}              animate={{ opacity: 1 }}              exit={{ opacity: 0 }}              transition={fade}              onClick={close}            />            <motion.div              key={`stage-${active.id}`}              className="pointer-events-none fixed inset-0 z-110 flex items-end justify-center p-3 sm:items-center sm:p-6"              initial={false}              animate={{ opacity: 1 }}              // Keep the stage mounted until the layout morph finishes              exit={{                opacity: 1,                transition: reduce ? { duration: 0.12 } : { duration: 0.38 },              }}            >              <motion.article                ref={dialogRef}                layoutId={reduce ? undefined : `card-${active.id}`}                role="dialog"                aria-modal="true"                aria-labelledby={`expand-title-${active.id}`}                transition={spring}                initial={                  reduce                    ? { opacity: 0, scale: 0.97, filter: "blur(2px)" }                    : { filter: "blur(2px)" }                }                animate={                  reduce                    ? { opacity: 1, scale: 1, filter: "blur(0px)" }                    : { filter: "blur(0px)" }                }                exit={                  reduce                    ? {                        opacity: 0,                        scale: 0.97,                        filter: "blur(2px)",                        transition: { duration: 0.12 },                      }                    : {                        filter: "blur(2px)",                        transition: { duration: 0.16, ease: EASE_OUT },                      }                }                className={cn(                  "pointer-events-auto relative flex w-full max-w-md flex-col overflow-hidden",                  // Outer radius larger than media so the morph reads as one surface                  "rounded-t-[calc(var(--radius-lg)+6px)] border border-(--color-rule) bg-(--color-paper) sm:rounded-[calc(var(--radius-lg)+4px)]",                  "shadow-[0_8px_24px_rgba(0,0,0,0.08),0_28px_64px_rgba(0,0,0,0.18)]",                  "max-h-[min(85dvh,560px)]",                )}              >                <CardMedia                  item={active}                  layoutId={reduce ? undefined : `surface-${active.id}`}                  transition={spring}                  className="aspect-16/10 h-auto shrink-0"                />
                <button                  ref={closeBtnRef}                  type="button"                  onClick={close}                  aria-label="Close"                  className={cn(                    "absolute top-3 right-3 z-20 flex size-10 items-center justify-center rounded-full",                    "border border-(--color-rule) bg-(--color-paper)/90 text-(--color-ink) shadow-sm backdrop-blur-md",                    "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96]",                    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",                  )}                >                  <X className="size-4" strokeWidth={2} />                </button>
                <div className="flex min-h-0 flex-col gap-2.5 overflow-y-auto p-5 pt-4">                  <motion.h2                    id={`expand-title-${active.id}`}                    layoutId={reduce ? undefined : `title-${active.id}`}                    className="pr-10 font-(family-name:--font-display) text-base font-medium tracking-[-0.02em] text-balance text-(--color-ink) sm:text-lg"                    transition={spring}                  >                    {active.title}                  </motion.h2>
                  <motion.p                    initial={reduce ? false : { opacity: 0, y: 8 }}                    animate={{ opacity: 1, y: 0 }}                    exit={{ opacity: 0, y: -8 }}                    transition={                      reduce                        ? { duration: 0.1 }                        : { duration: 0.2, ease: EASE_OUT, delay: 0.05 }                    }                    className="text-sm leading-relaxed text-pretty text-(--color-ink-2)"                  >                    {active.body}                  </motion.p>
                  <motion.button                    type="button"                    onClick={close}                    initial={reduce ? false : { opacity: 0, y: 8 }}                    animate={{ opacity: 1, y: 0 }}                    exit={{ opacity: 0, y: -6 }}                    transition={                      reduce                        ? { duration: 0.1 }                        : { duration: 0.2, ease: EASE_OUT, delay: 0.1 }                    }                    className={cn(                      "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)",                      "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96]",                      "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",                    )}                  >                    Close                  </motion.button>                </div>              </motion.article>            </motion.div>          </>        ) : null}      </AnimatePresence>,      document.body,    );
  return (    <LayoutGroup id={layoutGroupId}>      <div className={cn("w-full", className)}>        <div className="grid w-full grid-cols-2 gap-2.5 sm:gap-3">          {items.map((item) => {            const selected = activeId === item.id;            return (              <motion.button                key={item.id}                type="button"                layoutId={reduce || selected ? undefined : `card-${item.id}`}                transition={spring}                whileTap={reduce ? undefined : { scale: 0.96 }}                onClick={(e) => open(item.id, e.currentTarget)}                className={cn(                  "flex min-h-11 flex-col overflow-hidden rounded-md border border-(--color-rule) bg-(--color-paper) text-left sm:rounded-lg",                  "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.06)]",                  "transition-shadow duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",                  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",                  "dark:shadow-[0_8px_28px_rgba(0,0,0,0.35)]",                  "[@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)]",                  selected && "invisible pointer-events-none",                )}              >                <CardMedia                  item={item}                  layoutId={                    reduce || selected ? undefined : `surface-${item.id}`                  }                  transition={spring}                  className="aspect-16/10 h-auto"                />                <div className="flex flex-col gap-0.5 p-2.5 sm:gap-1 sm:p-3.5">                  <motion.h3                    layoutId={                      reduce || selected ? undefined : `title-${item.id}`                    }                    transition={spring}                    className="truncate font-(family-name:--font-display) text-[13px] font-medium tracking-tight text-(--color-ink) sm:text-sm"                  >                    {item.title}                  </motion.h3>                  <p className="line-clamp-2 text-[11px] leading-snug text-pretty text-(--color-ink-2) sm:text-xs sm:leading-relaxed">                    {item.summary}                  </p>                </div>              </motion.button>            );          })}        </div>        {overlay}      </div>    </LayoutGroup>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { ExpandableCard } from "@/components/ui/expandable-card";
<ExpandableCard  items={[    {      id: "woxly",      title: "Woxly",      summary: "Multi-tenant commerce storefronts.",      body: "Storefronts, vendors, and payments in one commerce stack.",      image: "/images/landing-woxly.png",    },  ]}/>

Props

PropTypeRequiredDefaultDescription
itemsExpandableCardItem[]YesCards with id, title, summary, body, image URL, and optional surface fallback.
classNamestringNoOptional class names on the root.

Best Practices

  1. Share layout ids for the elements that should feel continuous (shell, media, title).
  2. Materialize overlays with blur + scale — not opacity alone.
  3. Press scale 0.96 on grid tiles for instant down feedback.
  4. Under reduced motion, drop layout springs and crossfade with a short opacity transition.