Layout

Expandable Panel

Card that morphs in place into a centered detail dialog with a shared layout animation, dimmed backdrop, 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
 * 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>
    </>
  );
}