Expandable Panel
Layout

Expandable Panel

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.

Install

npx shadcn@latest add @paragon/expandable-panel

expandable-panel.tsx

"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>
    </>
  );
}