{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"expanding-card","type":"registry:component","title":"Expanding Card","description":"App Store-style card expansion: the card itself morphs into a centered modal over a heavily blurred backdrop via a shared layoutId FLIP — expanded content fades in once the surface lands, and the card shrinks back on close.","author":"UI Lab","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/expanding-card.tsx","type":"registry:component","target":"@components/motion/expanding-card.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/motion/expanding-card\n\nimport { ArrowUpRight, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// Shared-layout \"card expands into a modal\" pattern (App Store style): the\n// collapsed card *is* the trigger button, and shares a layoutId with the\n// expanded panel portalled to <body>. Motion FLIPs between the two boxes —\n// grow on open, shrink back on close — so it reads as one surface morphing\n// rather than a card that opens a separate dialog.\n//\n// The trigger stays mounted (just hidden) the whole time it's open, instead\n// of unmounting via AnimatePresence like a typical trigger/panel swap. That\n// keeps its place in the document flow so a surrounding grid of cards never\n// reflows while the modal is up — only its paint toggles.\n\nexport interface ExpandingCardProps {\n  /** Shown in both the collapsed card and the expanded modal; morphs continuously between them. */\n  title: ReactNode;\n  /** Shown in both states, under the title. */\n  summary?: ReactNode;\n  /** Expanded-only content — fades in once the card has landed. */\n  children: ReactNode;\n  /** Collapsed-state affordance text. */\n  expandHint?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Collapsed card. */\n  className?: string;\n  /** Expanded modal surface. */\n  modalClassName?: string;\n}\n\n// Both surfaces set radius via inline `style` (never a Tailwind class) —\n// Motion only interpolates `borderRadius`/`boxShadow` during a layout\n// animation when they arrive as animatable style values, not static CSS.\nconst CLOSED_RADIUS = 24;\nconst OPEN_RADIUS = 32;\n\n// Shared layoutId morph = a FLIP between the card's box and the modal's box.\n// SPRING_LAYOUT, not SPRING_PANEL: ease.ts scopes SPRING_LAYOUT to exactly\n// this (\"shared-layout glides... panels morphing between positions\"), while\n// SPRING_PANEL is tuned for overlays that materialize in place with no box\n// to carry across.\nconst MORPH = SPRING_LAYOUT;\n\nexport function ExpandingCard({\n  title,\n  summary,\n  children,\n  expandHint = \"Expand\",\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n  modalClassName,\n}: ExpandingCardProps) {\n  const uid = useId();\n  const surfaceId = `${uid}-surface`;\n  const titleId = `${uid}-title`;\n  const summaryId = `${uid}-summary`;\n\n  const reduce = useReducedMotion();\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [mounted, setMounted] = useState(false);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const closeButtonRef = useRef<HTMLButtonElement>(null);\n  const wasOpenRef = useRef(false);\n\n  const controlled = openProp !== undefined;\n  const open = controlled ? openProp : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n\n  useEffect(() => setMounted(true), []);\n\n  // Lock page scroll while the modal is open (same effect shape as MorphingModal).\n  useEffect(() => {\n    if (!open) return;\n    const prev = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = prev;\n    };\n  }, [open]);\n\n  // Escape closes.\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [open, setOpen]);\n\n  // Move focus into the panel on open — mirrors CommandPalette's autofocus.\n  useEffect(() => {\n    if (!open || !mounted) return;\n    const raf = requestAnimationFrame(() => closeButtonRef.current?.focus());\n    return () => cancelAnimationFrame(raf);\n  }, [open, mounted]);\n\n  // Return focus to the trigger once the modal has actually closed.\n  useEffect(() => {\n    if (open) {\n      wasOpenRef.current = true;\n      return;\n    }\n    if (wasOpenRef.current) {\n      wasOpenRef.current = false;\n      triggerRef.current?.focus();\n    }\n  }, [open]);\n\n  return (\n    <>\n      <motion.button\n        ref={triggerRef}\n        type=\"button\"\n        layoutId={reduce ? undefined : surfaceId}\n        transition={reduce ? undefined : MORPH}\n        style={{ borderRadius: CLOSED_RADIUS }}\n        onClick={() => setOpen(true)}\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        aria-hidden={open ? true : undefined}\n        className={cn(\n          \"group relative flex w-full flex-col gap-2 border border-border bg-card p-5 text-left shadow-sm outline-none transition-colors\",\n          \"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20\",\n          open && \"invisible\",\n          className,\n        )}\n      >\n        <motion.h3\n          layoutId={reduce ? undefined : titleId}\n          transition={reduce ? undefined : MORPH}\n          className=\"text-base font-semibold text-foreground\"\n        >\n          {title}\n        </motion.h3>\n        {summary ? (\n          <motion.p\n            layoutId={reduce ? undefined : summaryId}\n            transition={reduce ? undefined : MORPH}\n            className=\"text-sm text-muted-foreground\"\n          >\n            {summary}\n          </motion.p>\n        ) : null}\n        <span className=\"mt-1 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground\">\n          {expandHint}\n          <ArrowUpRight className=\"h-3.5 w-3.5\" />\n        </span>\n      </motion.button>\n\n      {mounted\n        ? createPortal(\n            <div\n              aria-hidden={!open}\n              className={cn(\n                \"fixed inset-0 z-[90]\",\n                open ? \"pointer-events-auto\" : \"pointer-events-none\",\n              )}\n            >\n              <motion.div\n                initial={false}\n                animate={{ opacity: open ? 1 : 0 }}\n                // Exit faster than enter (AGENTS.md motion rule) — mirrors\n                // CommandPalette's backdrop timing.\n                transition={{ duration: open ? 0.2 : 0.14, ease: EASE_OUT }}\n                onClick={() => setOpen(false)}\n                className={cn(\n                  \"absolute inset-0 bg-background/10 [backdrop-filter:blur(14px)_saturate(140%)] [-webkit-backdrop-filter:blur(14px)_saturate(140%)]\",\n                  open ? \"pointer-events-auto\" : \"pointer-events-none\",\n                )}\n              />\n              <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center p-4\">\n                <AnimatePresence initial={false}>\n                  {open ? (\n                    <motion.div\n                      key=\"panel\"\n                      layoutId={reduce ? undefined : surfaceId}\n                      role=\"dialog\"\n                      aria-modal=\"true\"\n                      aria-labelledby={titleId}\n                      initial={reduce ? { opacity: 0 } : undefined}\n                      animate={reduce ? { opacity: 1 } : undefined}\n                      exit={\n                        reduce\n                          ? {\n                              opacity: 0,\n                              transition: { duration: 0.14, ease: EASE_OUT },\n                            }\n                          : undefined\n                      }\n                      transition={\n                        reduce ? { duration: 0.16, ease: EASE_OUT } : MORPH\n                      }\n                      style={{ borderRadius: OPEN_RADIUS }}\n                      className={cn(\n                        \"pointer-events-auto relative flex max-h-[85vh] w-full max-w-lg flex-col gap-2 overflow-y-auto border border-border bg-card p-6 text-left shadow-2xl will-change-transform sm:p-8\",\n                        modalClassName,\n                      )}\n                    >\n                      <button\n                        ref={closeButtonRef}\n                        type=\"button\"\n                        onClick={() => setOpen(false)}\n                        aria-label=\"Close\"\n                        className=\"absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/20\"\n                      >\n                        <X className=\"h-4 w-4\" />\n                      </button>\n\n                      <motion.h3\n                        id={titleId}\n                        layoutId={reduce ? undefined : titleId}\n                        transition={reduce ? undefined : MORPH}\n                        className=\"pr-10 text-xl font-semibold text-foreground sm:text-2xl\"\n                      >\n                        {title}\n                      </motion.h3>\n                      {summary ? (\n                        <motion.p\n                          layoutId={reduce ? undefined : summaryId}\n                          transition={reduce ? undefined : MORPH}\n                          className=\"text-sm text-muted-foreground\"\n                        >\n                          {summary}\n                        </motion.p>\n                      ) : null}\n\n                      <motion.div\n                        initial={\n                          reduce ? { opacity: 0 } : { opacity: 0, y: 10 }\n                        }\n                        animate={{ opacity: 1, y: 0 }}\n                        exit={{\n                          opacity: 0,\n                          y: reduce ? 0 : 6,\n                          transition: { duration: 0.12, ease: EASE_OUT },\n                        }}\n                        transition={{\n                          duration: 0.24,\n                          ease: EASE_OUT,\n                          delay: reduce ? 0 : 0.1,\n                        }}\n                        className=\"mt-3 border-t border-border pt-4\"\n                      >\n                        {children}\n                      </motion.div>\n                    </motion.div>\n                  ) : null}\n                </AnimatePresence>\n              </div>\n            </div>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}