Card that morphs in place into a centered detail dialog with a shared layout animation, dimmed backdrop, focus trap, scroll lock, 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,
* Tab is trapped inside it, page scroll is locked, and focus returns to the
* card on collapse. 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);
// Immutable card rect captured at expand time (the panel grows in place from
// here instead of jumping to viewport center) and the clamped resting spot.
const anchorRef = React.useRef<{ top: number; left: number } | null>(null);
const [pos, setPos] = React.useState<{ top: number; left: number } | null>(
null,
);
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`;
// Anchor the expanded panel to the card's on-screen position, so it
// opens where the card was (clamped to the viewport once measured).
anchorRef.current = { top: rect.top, left: rect.left };
setPos({ top: rect.top, left: rect.left });
}
setOpen(true);
};
const collapse = React.useCallback(() => setOpen(false), []);
// Once the panel is mounted, clamp its anchored position so it stays fully
// on-screen (with an 8px margin) — measured, so it works at any panel size.
React.useLayoutEffect(() => {
if (!open) return;
const el = panelRef.current;
const base = anchorRef.current;
if (!el || !base) return;
const clamp = () => {
// offsetWidth/Height are layout-based, so they report the panel's true
// size even mid-morph (getBoundingClientRect would return the transformed
// box while the layout animation is in flight).
const w = el.offsetWidth;
const h = el.offsetHeight;
const margin = 8;
const maxLeft = Math.max(margin, window.innerWidth - w - margin);
const maxTop = Math.max(margin, window.innerHeight - h - margin);
const left = Math.min(Math.max(base.left, margin), maxLeft);
const top = Math.min(Math.max(base.top, margin), maxTop);
setPos((prev) =>
prev && prev.left === left && prev.top === top ? prev : { top, left },
);
};
clamp();
window.addEventListener("resize", clamp);
return () => window.removeEventListener("resize", clamp);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
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();
return;
}
// Minimal focus trap: Tab cycles within the dialog.
if (event.key !== "Tab" || !panelRef.current) return;
const focusables = Array.from(
panelRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
);
if (focusables.length === 0) {
event.preventDefault();
panelRef.current.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || active === panelRef.current)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
// Lock page scroll behind the dialog for the duration.
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const frame = requestAnimationFrame(() => panelRef.current?.focus());
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
cancelAnimationFrame(frame);
};
}, [open, collapse]);
// Return focus to the card once the dialog has fully exited — while the
// panel animates out it still holds focus, so this must wait for unmount.
const returnFocus = React.useCallback(() => {
cardRef.current?.focus({ preventScroll: true });
}, []);
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={cn(
"h-full w-full cursor-pointer bg-card p-5 text-left text-card-foreground shadow-border transition-[box-shadow,scale] duration-150 ease-out hover:shadow-border-hover",
!isStatic && "active:scale-[0.98]",
)}
>
{header}
</motion.div>
)}
</div>
<AnimatePresence onExitComplete={returnFocus}>
{open && (
<div className="fixed inset-0 z-50">
<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}
// Anchored to the card's slot (clamped on-screen), so the panel
// expands in place rather than jumping to viewport center.
style={{
borderRadius: 16,
top: pos?.top ?? 0,
left: pos?.left ?? 0,
}}
className={cn(
"absolute max-h-[calc(100dvh-16px)] w-full max-w-[min(32rem,calc(100vw-1rem))] overflow-y-auto 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>
</>
);
}