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.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,  LayoutGroup,  motion,  useReducedMotion,} from "motion/react";import { ArrowUpRight, Plus } from "lucide-react";import { cn } from "@/lib/utils";
export type ExpandableCardItem = {  id: string;  title: string;  summary: string;  body: React.ReactNode;  image?: string;  /** Fallback fill when `image` is omitted. */  surface?: string;  /** Short facts shown as chips in the expanded view, e.g. ["2024", "Next.js"]. */  meta?: string[];  /** Adds a link button to the expanded view. */  href?: string;  hrefLabel?: string;};
export type ExpandableCardProps = {  items: ExpandableCardItem[];  className?: string;  /** Grid columns from the `sm` breakpoint up. */  columns?: 2 | 3;  onOpenChange?: (id: string | null) => void;};
const EASE_OUT = [0.23, 1, 0.32, 1] as const;const RADIUS = 18;const FOCUSABLE =  'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function Media({ item, className, zoom }: { item: ExpandableCardItem; className?: string; zoom?: boolean }) {  return (    <div className={cn("relative w-full overflow-hidden bg-(--color-paper-2)", className)}>      {item.image ? (        // eslint-disable-next-line @next/next/no-img-element        <img          src={item.image}          alt=""          draggable={false}          className={cn(            "size-full object-cover object-top",            zoom &&              "transition-transform duration-700 ease-[cubic-bezier(0.23,1,0.32,1)] [@media(hover:hover)]:group-hover:scale-[1.04]",          )}        />      ) : (        <div          className="size-full"          style={{            background: item.surface ?? "linear-gradient(145deg, var(--color-paper-2), var(--color-rule))",          }}        />      )}      <div aria-hidden className="pointer-events-none absolute inset-0 shadow-[inset_0_-1px_0_var(--color-rule)]" />    </div>  );}
export function ExpandableCard({ items, className, columns = 2, onOpenChange }: ExpandableCardProps) {  const reduce = !!useReducedMotion();  const uid = useId();  const [activeId, setActiveId] = useState<string | null>(null);  const [mounted, setMounted] = useState(false);  const active = items.find((i) => i.id === activeId) ?? null;  const dialogRef = useRef<HTMLDivElement>(null);  const triggers = useRef(new Map<string, HTMLButtonElement>());  const lastId = useRef<string | null>(null);
  const spring = reduce ? { duration: 0.18 } : { type: "spring" as const, bounce: 0.12, duration: 0.5 };  const lid = (part: string, id: string) => (reduce ? undefined : `${uid}-${part}-${id}`);
  const setOpen = useCallback(    (id: string | null) => {      if (id) lastId.current = id;      setActiveId(id);      onOpenChange?.(id);    },    [onOpenChange],  );
  useEffect(() => setMounted(true), []);
  // Escape, focus trap and scroll lock while open; focus returns to the card on close.  useEffect(() => {    if (!activeId) {      const id = lastId.current;      if (!id) return;      const t = window.setTimeout(() => triggers.current.get(id)?.focus({ preventScroll: true }), reduce ? 50 : 420);      return () => window.clearTimeout(t);    }
    const raf = requestAnimationFrame(() =>      dialogRef.current?.querySelector<HTMLElement>("[data-autofocus]")?.focus({ preventScroll: true }),    );    const onKey = (e: KeyboardEvent) => {      if (e.key === "Escape") {        e.preventDefault();        setOpen(null);        return;      }      if (e.key !== "Tab") return;      const nodes = dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE);      if (!nodes?.length) 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();      }    };    const html = document.documentElement;    const gutter = window.innerWidth - html.clientWidth;    const prev = { overflow: html.style.overflow, pad: html.style.paddingRight };    html.style.overflow = "hidden";    if (gutter > 0) html.style.paddingRight = `${gutter}px`;    document.addEventListener("keydown", onKey);    return () => {      cancelAnimationFrame(raf);      document.removeEventListener("keydown", onKey);      html.style.overflow = prev.overflow;      html.style.paddingRight = prev.pad;    };  }, [activeId, reduce, setOpen]);
  const reveal = (i: number) =>    reduce      ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: 0.08 } } }      : {          initial: { opacity: 0, y: 10, filter: "blur(4px)" },          animate: {            opacity: 1,            y: 0,            filter: "blur(0px)",            transition: { duration: 0.4, ease: EASE_OUT, delay: 0.12 + i * 0.05 },          },          exit: { opacity: 0, filter: "blur(4px)", transition: { duration: 0.1 } },        };
  const overlay =    mounted &&    createPortal(      <AnimatePresence>        {active ? (          <div key={active.id} className="fixed inset-0 z-100">            <motion.div              aria-hidden              className="absolute inset-0 bg-black/35 dark:bg-black/55"              initial={{ opacity: 0, backdropFilter: "blur(0px)" }}              animate={{ opacity: 1, backdropFilter: reduce ? "blur(0px)" : "blur(8px)" }}              exit={{ opacity: 0, backdropFilter: "blur(0px)" }}              transition={{ duration: 0.3, ease: EASE_OUT }}              onClick={() => setOpen(null)}            />            <div className="pointer-events-none absolute inset-0 flex items-end justify-center p-3 sm:items-center sm:p-6">              <motion.div                ref={dialogRef}                role="dialog"                aria-modal="true"                aria-labelledby={`${uid}-title-${active.id}`}                layoutId={lid("card", active.id)}                transition={spring}                initial={reduce ? { opacity: 0 } : undefined}                animate={reduce ? { opacity: 1 } : undefined}                exit={reduce ? { opacity: 0 } : undefined}                style={{ borderRadius: RADIUS }}                className={cn(                  "pointer-events-auto relative flex max-h-[min(88dvh,640px)] w-full max-w-lg flex-col overflow-hidden",                  "bg-(--color-paper) shadow-[inset_0_0_0_1px_var(--color-rule),0_24px_64px_-12px_rgba(0,0,0,0.35)]",                )}              >                <motion.div layoutId={lid("media", active.id)} transition={spring} className="shrink-0">                  <Media item={active} className="aspect-16/9" />                </motion.div>
                <motion.button                  type="button"                  data-autofocus                  aria-label="Close"                  onClick={() => setOpen(null)}                  layoutId={lid("icon", active.id)}                  transition={spring}                  style={{ borderRadius: 999 }}                  className={cn(                    "absolute top-3 right-3 z-10 flex size-9 items-center justify-center outline-none",                    "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",                    "transition-[background-color,transform] duration-150 hover:bg-(--color-paper) active:scale-[0.92]",                    "focus-visible:ring-2 focus-visible:ring-(--color-focus)",                  )}                >                  <motion.span                    initial={{ rotate: reduce ? 45 : 0 }}                    animate={{ rotate: 45 }}                    transition={spring}                    className="flex"                  >                    <Plus className="size-4" strokeWidth={2} />                  </motion.span>                </motion.button>
                <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">                  <motion.div                    id={`${uid}-title-${active.id}`}                    layoutId={lid("title", active.id)}                    transition={spring}                    className="w-fit font-(family-name:--font-display) text-[19px] font-semibold tracking-[-0.02em] text-(--color-ink)"                  >                    {active.title}                  </motion.div>                  <motion.div {...reveal(0)} className="mt-1 text-[13.5px] text-(--color-neutral)">                    {active.summary}                  </motion.div>                  {active.meta?.length ? (                    <motion.div {...reveal(1)} className="mt-3 flex flex-wrap gap-1.5">                      {active.meta.map((m) => (                        <span                          key={m}                          className="inline-flex h-6 items-center rounded-full bg-(--color-paper-2) px-2.5 text-[12px] text-(--color-ink-2)"                        >                          {m}                        </span>                      ))}                    </motion.div>                  ) : null}                  <motion.div {...reveal(2)} className="mt-4 text-[14px] leading-relaxed text-pretty text-(--color-ink-2)">                    {active.body}                  </motion.div>                  {active.href ? (                    <motion.div {...reveal(3)} className="mt-5">                      <a                        href={active.href}                        target="_blank"                        rel="noreferrer"                        className={cn(                          "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",                          "transition-transform duration-150 active:scale-[0.96]",                          "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",                        )}                      >                        {active.hrefLabel ?? "Visit"}                        <ArrowUpRight className="size-3.5" strokeWidth={2} aria-hidden />                      </a>                    </motion.div>                  ) : null}                </div>              </motion.div>            </div>          </div>        ) : null}      </AnimatePresence>,      document.body,    );
  return (    <LayoutGroup id={uid}>      <div        role="list"        className={cn(          "grid w-full grid-cols-2 gap-2.5 sm:gap-3",          columns === 3 && "sm:grid-cols-3",          className,        )}      >        {items.map((item) => {          const open = activeId === item.id;          return (            <div role="listitem" key={item.id} className="min-w-0">              <motion.button                ref={(el) => {                  if (el) triggers.current.set(item.id, el);                  else triggers.current.delete(item.id);                }}                type="button"                aria-haspopup="dialog"                aria-expanded={open}                onClick={() => setOpen(item.id)}                layoutId={open ? undefined : lid("card", item.id)}                transition={spring}                whileTap={reduce ? undefined : { scale: 0.97 }}                style={{ borderRadius: RADIUS }}                className={cn(                  "group relative flex w-full flex-col overflow-hidden bg-(--color-paper) text-left outline-none",                  "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04)]",                  "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)]",                  "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",                  open && "invisible",                )}              >                <motion.div layoutId={open ? undefined : lid("media", item.id)} transition={spring}>                  <Media item={item} zoom className="aspect-16/10" />                </motion.div>                <motion.span                  aria-hidden                  layoutId={open ? undefined : lid("icon", item.id)}                  transition={spring}                  style={{ borderRadius: 999 }}                  className={cn(                    "absolute top-2 right-2 flex size-7 items-center justify-center",                    "bg-(--color-paper)/80 text-(--color-ink) shadow-[0_0_0_1px_var(--color-rule)] backdrop-blur-md",                    "transition-transform duration-300 [@media(hover:hover)]:group-hover:rotate-90",                  )}                >                  <Plus className="size-3.5" strokeWidth={2} />                </motion.span>                <div className="flex min-w-0 flex-col gap-0.5 p-3 sm:p-3.5">                  <motion.span                    layoutId={open ? undefined : lid("title", item.id)}                    transition={spring}                    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]"                  >                    {item.title}                  </motion.span>                  <span className="line-clamp-2 text-[12px] leading-snug text-pretty text-(--color-neutral)">                    {item.summary}                  </span>                </div>              </motion.button>            </div>          );        })}      </div>      {overlay}    </LayoutGroup>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { ExpandableCard } from "@/components/ui/expandable-card";
<ExpandableCard  columns={2}  items={[    {      id: "woxly",      title: "Woxly",      summary: "Online stores for small sellers.",      meta: ["Commerce", "Next.js", "2025"],      body: "Storefronts, orders and payments in one place.",      image: "/images/landing-woxly.webp",      href: "https://woxly.store",      hrefLabel: "Visit Woxly",    },  ]}/>

Props

PropTypeRequiredDefaultDescription
itemsExpandableCardItem[]Yes—Cards with id, title, summary, body (text or JSX), and optional image, surface, meta chips, href and hrefLabel.
columns2 | 3No2Grid columns from the sm breakpoint up. Phones always use two.
onOpenChange(id: string | null) => voidNo—Fires when a card opens or the dialog closes.
classNamestringNo—Class names on the grid.

Best Practices

  1. Keep summary to one line; it's clamped on the card and repeated under the title in the dialog.
  2. Use landscape images. They're cropped from the top, so put the important part there.
  3. Long bodies scroll inside the dialog, which is capped at 88% of the viewport height.
  4. Reduced motion swaps the grow animation for a fade.