{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-card",
  "type": "registry:ui",
  "title": "Expandable card",
  "description": "Give a dense card more room when requested.",
  "categories": [
    "disclosure"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/expandable-card/expandable-card.tsx",
      "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { ChevronDown as NavArrowDown } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface ExpandableCardProps extends Omit<\n  ComponentPropsWithoutRef<\"article\">,\n  | \"title\"\n  | \"children\"\n  | \"onAnimationStart\"\n  | \"onAnimationEnd\"\n  | \"onAnimationIteration\"\n  | \"onDrag\"\n  | \"onDragStart\"\n  | \"onDragEnd\"\n  | \"onDragOver\"\n  | \"onDragLeave\"\n  | \"onDragEnter\"\n  | \"onDragExit\"\n  | \"onDrop\"\n> {\n  title: string;\n  description?: string;\n  children: ReactNode;\n  defaultExpanded?: boolean;\n  /** Collapsed width cap in px. The card is centered and never wider than its container. Fills the container when omitted. */\n  width?: number;\n  /** Expanded width cap in px, so the card can grow sideways into more room. Defaults to `width`. */\n  expandedWidth?: number;\n}\n\n/**\n * One morph: the box grows in width and height on the same spring, which never overshoots, so close is the exact mirror of open.\n * Size is animated for real (not with a scale), so the text, border, and corner radius never stretch.\n */\nconst morph = motionTokens.spring.smooth;\n/** On close the details fade out first, then the box starts to shrink; on open they fade in once the box has made room. */\nconst closeHold = 0.06;\n/** A close that reverses an open still in flight skips the hold, so the spring turns around with its velocity instead of stalling. */\nconst settleTime = 450;\nconst boxTransition = (expanded: boolean, hold: boolean): Transition =>\n  expanded || !hold ? morph : { ...morph, delay: closeHold };\nconst contentTransition = (expanded: boolean): Transition =>\n  expanded\n    ? { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard], delay: 0.12 }\n    : { duration: 0.1, ease: [...motionTokens.ease.standard] };\nconst still: Transition = { duration: 0 };\n\n/** Changed words in the summary rise in; unchanged words hold still. */\nconst wordMotion: Variants = {\n  enter: { opacity: 0, y: \".35em\", filter: `blur(${motionTokens.blur.soft}px)` },\n  center: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n  },\n  exit: {\n    opacity: 0,\n    y: \"-.3em\",\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n  },\n};\n\nfunction RollingText({ text, reduced }: { text: string; reduced: boolean }) {\n  return (\n    <span className=\"relative block\">\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {text.split(/(\\s+)/).map((word, index) => (\n            <motion.span\n              key={`${index}:${word}`}\n              className=\"inline-block whitespace-pre\"\n              variants={wordMotion}\n              initial={reduced ? false : \"enter\"}\n              animate=\"center\"\n              exit={reduced ? undefined : \"exit\"}\n            >\n              {word}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </span>\n  );\n}\n\nexport function ExpandableCard({\n  title,\n  description,\n  children,\n  defaultExpanded = false,\n  width,\n  expandedWidth,\n  className,\n  style,\n  onKeyDown,\n  ...rest\n}: ExpandableCardProps) {\n  const [expanded, setExpanded] = useState(defaultExpanded);\n  const [hold, setHold] = useState(true);\n  const lastToggle = useRef(0);\n  // The room the card can use, measured from its centering track. Until it is known, CSS caps the width.\n  const [room, setRoom] = useState<number | null>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const panelId = useId();\n  const reduceMotion = useReducedMotion() ?? false;\n\n  useLayoutEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n    const measure = () => setRoom(track.clientWidth);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(track);\n    return () => observer.disconnect();\n  }, []);\n\n  const openCap = expandedWidth ?? width;\n  const fit = (cap: number | undefined) => (room === null ? undefined : Math.min(room, cap ?? room));\n  const boxWidth = fit(expanded ? openCap : width);\n  // The details are laid out at their final width the whole time, so nothing reflows while the box changes size.\n  const innerWidth = fit(openCap);\n\n  // The width lives in a motion value: the first measurement lands without motion, later changes spring from the current width and velocity.\n  const boxWidthValue = useMotionValue<number | string>(\"100%\");\n  const placed = useRef(false);\n  const widthTransition = reduceMotion ? still : boxTransition(expanded, hold);\n  useLayoutEffect(() => {\n    if (boxWidth === undefined) return;\n    if (!placed.current) {\n      placed.current = true;\n      boxWidthValue.jump(boxWidth);\n      return;\n    }\n    const controls = animate(boxWidthValue, boxWidth, widthTransition);\n    return () => controls.stop();\n  }, [boxWidth, boxWidthValue]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // Caps for the first paint, before the room is measured. Once measured, the width is animated in px, so the CSS cap steps aside and never clamps the morph.\n  const cap =\n    room !== null\n      ? \"max-w-full\"\n      : expanded\n        ? \"max-w-[var(--expandable-card-expanded-width,var(--expandable-card-width,100%))]\"\n        : \"max-w-[var(--expandable-card-width,100%)]\";\n  const capVars = {\n    \"--expandable-card-width\": width ? `${width}px` : undefined,\n    \"--expandable-card-expanded-width\": openCap ? `${openCap}px` : undefined,\n  } as CSSProperties;\n\n  const toggle = (next: boolean) => {\n    const now = performance.now();\n    setHold(now - lastToggle.current > settleTime);\n    lastToggle.current = now;\n    setExpanded(next);\n  };\n  const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented || event.key !== \"Escape\" || !expanded) return;\n    event.preventDefault();\n    toggle(false);\n    triggerRef.current?.focus();\n  };\n\n  // The track centers the card and measures the room it may grow into.\n  return (\n    <div ref={trackRef} className=\"flex w-full min-w-0 justify-center\">\n      <motion.article\n        {...rest}\n        className={cn(\n          \"w-full overflow-hidden rounded-surface border border-line bg-surface shadow-resting\",\n          cap,\n          className,\n        )}\n        data-expanded={expanded}\n        data-measured={room === null ? undefined : \"\"}\n        style={{ ...style, ...capVars, width: boxWidthValue }}\n        onKeyDown={handleKeyDown}\n      >\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          className=\"group/trigger flex min-h-16 w-full cursor-pointer items-center justify-between gap-5 border-0 bg-transparent px-5 py-4 text-left text-ink [-webkit-tap-highlight-color:transparent] transition-[background-color] duration-fast ease-standard hover:bg-hover active:bg-hover motion-reduce:transition-none\"\n          aria-expanded={expanded}\n          aria-controls={panelId}\n          onClick={() => toggle(!expanded)}\n        >\n          <span className=\"min-w-0\">\n            <strong className=\"block text-(length:--text-sm) font-medium\">{title}</strong>\n            {description && (\n              <span className=\"mt-1 block text-(length:--text-xs) text-ink-3 tabular-nums\">\n                <RollingText text={description} reduced={reduceMotion} />\n              </span>\n            )}\n          </span>\n          <motion.span\n            className={cn(\n              \"inline-flex flex-none items-center justify-center text-ink-3 transition-[color] duration-fast ease-standard group-hover/trigger:text-ink motion-reduce:transition-none\",\n              expanded && \"text-ink\",\n            )}\n            initial={false}\n            animate={{ rotate: expanded ? 180 : 0 }}\n            transition={reduceMotion ? still : boxTransition(expanded, hold)}\n          >\n            <NavArrowDown width={18} height={18} aria-hidden=\"true\" />\n          </motion.span>\n        </button>\n        {/* The panel stays mounted so a second click mid-animation reverses from where it is; inert keeps closed details out of reach.\n          The divider is an inset line rather than a border, so a collapsed panel is truly zero height and nothing pops at the end of the close. */}\n        <motion.div\n          id={panelId}\n          className=\"overflow-hidden bg-hover text-(length:--text-sm) text-ink-2 shadow-[inset_0_1px_0_var(--line)]\"\n          inert={!expanded}\n          initial={false}\n          animate={{ height: expanded ? \"auto\" : 0 }}\n          transition={reduceMotion ? still : boxTransition(expanded, hold)}\n        >\n          <motion.div\n            className=\"max-w-none px-5 pt-4 pb-5\"\n            style={innerWidth === undefined ? undefined : { width: innerWidth - 2 }}\n            initial={false}\n            animate={{ opacity: expanded ? 1 : 0 }}\n            transition={reduceMotion ? still : contentTransition(expanded)}\n          >\n            {children}\n          </motion.div>\n        </motion.div>\n      </motion.article>\n    </div>\n  );\n}\n\nexport default ExpandableCard;\n",
      "type": "registry:component",
      "target": "@components/iris/expandable-card/expandable-card.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/expandable-card",
  "meta": {
    "kind": "component",
    "group": "Overlays & disclosure",
    "export": "ExpandableCard",
    "tags": [
      "disclosure",
      "layout"
    ],
    "docs": "https://irisui.dev/components/expandable-card"
  }
}
