Card that morphs in place into a centered detail dialog with a shared layout animation, dimmed backdrop, and Escape/click-out collapse.
npx shadcn@latest add @paragon/expandable-panel"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ExpandablePanelProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Summary content — visible collapsed and carried into the expanded panel. */
header: React.ReactNode;
/** Detail content revealed only while expanded. */
children?: React.ReactNode;
/** Styles the expanded surface (size, padding). */
expandedClassName?: string;
/** Accessible name for the expanded dialog. */
label?: string;
/** Swaps states instantly with a plain fade, no morph. */
static?: boolean;
}
/**
* A card that expands in place into a centered detail panel via a shared
* motion/react layout animation (zero-bounce spring; borderRadius set through
* `style` on both states so the morph doesn't distort corners). A backdrop
* dims the page; Escape and click-out collapse; focus moves into the panel
* and returns to the card. While open, a size-locked placeholder holds the
* card's slot so surrounding layout never shifts. Reduced motion swaps with
* a fade only.
*/
export function ExpandablePanel({
header,
children,
className,
expandedClassName,
label = "Details",
static: isStatic = false,
...props
}: ExpandablePanelProps) {
const id = React.useId();
const reducedMotion = useReducedMotion();
const [open, setOpen] = React.useState(false);
const placeholderRef = React.useRef<HTMLDivElement>(null);
const panelRef = React.useRef<HTMLDivElement>(null);
const cardRef = React.useRef<HTMLDivElement>(null);
const instant = isStatic || !!reducedMotion;
const spring = instant
? { duration: 0 }
: ({ type: "spring", duration: 0.35, bounce: 0 } as const);
const expand = () => {
// Lock the card's slot so the grid/stack doesn't reflow while it's away.
const node = placeholderRef.current;
if (node) {
const rect = node.getBoundingClientRect();
node.style.width = `${rect.width}px`;
node.style.height = `${rect.height}px`;
}
setOpen(true);
};
const collapse = React.useCallback(() => setOpen(false), []);
React.useEffect(() => {
if (!open) {
const node = placeholderRef.current;
if (node) {
node.style.width = "";
node.style.height = "";
}
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") collapse();
};
document.addEventListener("keydown", onKeyDown);
const frame = requestAnimationFrame(() => panelRef.current?.focus());
return () => {
document.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(frame);
};
}, [open, collapse]);
// Return focus to the card once it remounts.
React.useEffect(() => {
if (!open && cardRef.current && document.activeElement === document.body) {
// Only steal focus back if the dialog had it (it left with the unmount).
cardRef.current.focus({ preventScroll: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<>
<div ref={placeholderRef} className={className} {...props}>
{!open && (
<motion.div
ref={cardRef}
layoutId={`expandable-panel-${id}`}
role="button"
tabIndex={0}
aria-haspopup="dialog"
aria-expanded={false}
data-slot="expandable-panel"
onClick={expand}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
expand();
}
}}
transition={spring}
style={{ borderRadius: 12 }}
className="h-full w-full cursor-pointer bg-card p-5 text-left text-card-foreground shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover"
>
{header}
</motion.div>
)}
</div>
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
aria-hidden
className="absolute inset-0 bg-black/40 dark:bg-black/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
transition={{ duration: 0.2, ease: "easeOut" }}
onClick={collapse}
/>
<motion.div
ref={panelRef}
layoutId={`expandable-panel-${id}`}
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
data-slot="expandable-panel-content"
transition={spring}
style={{ borderRadius: 16 }}
className={cn(
"relative w-full max-w-lg bg-card p-5 text-card-foreground shadow-overlay outline-none",
expandedClassName,
)}
>
{header}
<motion.div
initial={{ opacity: 0, y: instant ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0, transition: { duration: 0.1 } }}
transition={
instant
? { duration: 0 }
: { type: "spring", duration: 0.35, bounce: 0, delay: 0.08 }
}
>
{children}
</motion.div>
<button
type="button"
aria-label="Collapse"
onClick={collapse}
className="pressable absolute top-3 right-3 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2"
>
<X className="size-3.5" />
</button>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}