{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-panel",
  "title": "Expandable Panel",
  "description": "A floating panel that grows from a compact icon trigger and holds arbitrary content.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "path": "registry/base/ui/expandable-panel.tsx",
      "content": "\"use client\";\n\nimport { ChevronsUpDown } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst PANEL_TRANSITION = {\n  type: \"spring\",\n  duration: 0.24,\n  bounce: 0,\n} as const;\nconst CONTENT_TRANSITION = {\n  duration: 0.2,\n  ease: [0.16, 1, 0.3, 1],\n} as const;\nconst REDUCED_TRANSITION = { duration: 0 } as const;\n// Single source of truth for the panel's height cap. Applied to the content\n// region so consumers can add a scroll area / pinned footer without knowing it.\nconst CONTENT_MAX_HEIGHT =\n  \"var(--expandable-panel-max-height, min(15.5rem, calc(100dvh - 8rem)))\";\n\ntype ExpandablePanelContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n};\n\nconst ExpandablePanelContext =\n  React.createContext<ExpandablePanelContextValue | null>(null);\n\n/**\n * Access the expanding panel's open state from within its `children`, e.g. to\n * collapse the panel after an action. Throws when used outside the component.\n */\nexport function useExpandablePanel() {\n  const context = React.useContext(ExpandablePanelContext);\n\n  if (!context) {\n    throw new Error(\n      \"useExpandablePanel must be used within an <ExpandablePanel>.\",\n    );\n  }\n\n  return context;\n}\n\nexport type ExpandablePanelClassNames = {\n  panel?: string;\n  content?: string;\n  trigger?: string;\n};\n\nexport type ExpandablePanelProps = Omit<\n  React.ComponentProps<\"aside\">,\n  \"children\"\n> & {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  triggerIcon?: React.ReactNode;\n  openLabel?: string;\n  closeLabel?: string;\n  closeOnEscape?: boolean;\n  /**\n   * Collapse when a pointer goes down outside the panel. Content that portals\n   * outside the panel's subtree (Select, Popover, Dialog) registers as\n   * \"outside\"; set this to false and close manually in that case.\n   */\n  closeOnOutsideClick?: boolean;\n  classNames?: ExpandablePanelClassNames;\n  children?: React.ReactNode;\n};\n\n/**\n * A floating panel that grows in width and height from a compact icon trigger\n * in its top-right corner. It is intentionally content-agnostic.\n *\n * The panel is bounded by `--expandable-panel-width` and\n * `--expandable-panel-max-height` and clips overflow, so the morph never runs\n * off-screen. The content region is a flex column capped at that height, so\n * tall content can add a `flex-1` scroll area (and, e.g., a `shrink-0` pinned\n * footer) without re-declaring the cap.\n *\n * The trigger overlays the top-right corner; content that reaches it should\n * clear it with `pr-[var(--expandable-panel-trigger-inset)]` (defaults to the\n * 2rem trigger size).\n */\nexport function ExpandablePanel({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  triggerIcon,\n  openLabel = \"Expand\",\n  closeLabel = \"Collapse\",\n  closeOnEscape = true,\n  closeOnOutsideClick = true,\n  className,\n  classNames,\n  children,\n  \"aria-label\": ariaLabel = \"Expanding panel\",\n  onKeyDown,\n  ref,\n  ...props\n}: ExpandablePanelProps) {\n  const generatedId = React.useId();\n  const panelId = `${generatedId}-panel`;\n  const rootRef = React.useRef<HTMLElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const shouldReduceMotion = useReducedMotion();\n  const openControlled = open !== undefined;\n  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);\n  const isOpen = openControlled ? open : uncontrolledOpen;\n\n  const panelTransition = shouldReduceMotion\n    ? REDUCED_TRANSITION\n    : PANEL_TRANSITION;\n  const contentTransition = shouldReduceMotion\n    ? REDUCED_TRANSITION\n    : CONTENT_TRANSITION;\n\n  const setOpen = React.useCallback(\n    (nextOpen: boolean) => {\n      if (!openControlled) {\n        setUncontrolledOpen(nextOpen);\n      }\n\n      onOpenChange?.(nextOpen);\n    },\n    [onOpenChange, openControlled],\n  );\n\n  React.useEffect(() => {\n    if (!isOpen || !closeOnOutsideClick) {\n      return;\n    }\n\n    function handlePointerDown(event: PointerEvent) {\n      const root = rootRef.current;\n\n      if (\n        !root ||\n        !(event.target instanceof Node) ||\n        // A target detached by the very interaction being handled (e.g. a\n        // removed list row) can no longer prove it was outside the panel.\n        !event.target.isConnected ||\n        root.contains(event.target)\n      ) {\n        return;\n      }\n\n      setOpen(false);\n    }\n\n    document.addEventListener(\"pointerdown\", handlePointerDown, true);\n\n    return () =>\n      document.removeEventListener(\"pointerdown\", handlePointerDown, true);\n  }, [closeOnOutsideClick, isOpen, setOpen]);\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLElement>) => {\n      onKeyDown?.(event);\n\n      if (\n        event.defaultPrevented ||\n        !closeOnEscape ||\n        !isOpen ||\n        event.key !== \"Escape\"\n      ) {\n        return;\n      }\n\n      event.stopPropagation();\n      setOpen(false);\n      triggerRef.current?.focus();\n    },\n    [closeOnEscape, isOpen, onKeyDown, setOpen],\n  );\n\n  const contextValue = React.useMemo<ExpandablePanelContextValue>(\n    () => ({ open: isOpen, setOpen }),\n    [isOpen, setOpen],\n  );\n\n  // The root node is needed internally (outside-click detection) *and* by\n  // consumers, so the consumer's ref is merged in rather than overwritten.\n  const setRootRef = React.useCallback(\n    (node: HTMLElement | null) => {\n      rootRef.current = node;\n\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else if (ref) {\n        ref.current = node;\n      }\n    },\n    [ref],\n  );\n\n  return (\n    <ExpandablePanelContext.Provider value={contextValue}>\n      <aside\n        ref={setRootRef}\n        aria-label={ariaLabel}\n        data-slot=\"expandable-panel\"\n        data-state={isOpen ? \"open\" : \"closed\"}\n        onKeyDown={handleKeyDown}\n        className={cn(\n          \"relative inline-flex max-w-[calc(100vw-1.5rem)] justify-end\",\n          className,\n        )}\n        {...props}\n      >\n        <div className=\"relative flex justify-end\">\n          <motion.div\n            id={panelId}\n            initial={false}\n            animate={isOpen ? { height: \"auto\" } : { height: \"2rem\" }}\n            transition={panelTransition}\n            style={{ transformOrigin: \"top right\" }}\n            className={cn(\n              \"relative overflow-hidden rounded-lg border border-border/70 bg-popover/90 text-popover-foreground shadow-md backdrop-blur-lg transition-[width] duration-240 ease-[cubic-bezier(0.23,1,0.32,1)] will-change-[width,height] motion-reduce:transition-none\",\n              isOpen\n                ? \"w-[min(var(--expandable-panel-width,17rem),calc(100vw-1.5rem))]\"\n                : \"size-8\",\n              classNames?.panel,\n            )}\n          >\n            <AnimatePresence initial={false}>\n              {isOpen ? (\n                <motion.div\n                  key=\"expandable-panel-content\"\n                  initial={\n                    shouldReduceMotion\n                      ? false\n                      : { opacity: 0, scale: 0.99, filter: \"blur(1.5px)\" }\n                  }\n                  animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                  exit={\n                    shouldReduceMotion\n                      ? { opacity: 0 }\n                      : { opacity: 0, scale: 0.98 }\n                  }\n                  transition={contentTransition}\n                  style={{\n                    transformOrigin: \"top right\",\n                    maxHeight: CONTENT_MAX_HEIGHT,\n                  }}\n                  className={cn(\n                    \"flex flex-col [--expandable-panel-trigger-inset:2rem] will-change-[filter,transform,opacity]\",\n                    classNames?.content,\n                  )}\n                >\n                  {children}\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n          </motion.div>\n\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            aria-controls={panelId}\n            aria-expanded={isOpen}\n            aria-label={isOpen ? closeLabel : openLabel}\n            onClick={() => setOpen(!isOpen)}\n            className={cn(\n              \"extend-touch-target absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-md text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none\",\n              classNames?.trigger,\n            )}\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"flex size-8 shrink-0 items-center justify-center rounded-md bg-transparent text-current\"\n            >\n              {triggerIcon ?? <ChevronsUpDown className=\"size-3.5\" />}\n            </span>\n          </button>\n        </div>\n      </aside>\n    </ExpandablePanelContext.Provider>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/expandable-panel.tsx"
    }
  ],
  "meta": {
    "tags": [
      "expandable-panel",
      "floating-panel",
      "disclosure",
      "content-agnostic"
    ],
    "effects": [
      "morph",
      "expand",
      "blur"
    ]
  },
  "categories": [
    "overlay"
  ],
  "type": "registry:ui"
}