{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/expandable-card/expandable-card.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./expandable-card.module.css\";\n\nexport interface ExpandableCardProps extends Omit<ComponentPropsWithoutRef<\"article\">, \"title\" | \"children\" | \"onAnimationStart\" | \"onAnimationEnd\" | \"onAnimationIteration\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onDragOver\" | \"onDragLeave\" | \"onDragEnter\" | \"onDragExit\" | \"onDrop\"> {\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 => expanded || !hold ? morph : { ...morph, delay: closeHold };\nconst contentTransition = (expanded: boolean): Transition => 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: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } },\n  exit: { opacity: 0, y: \"-.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } },\n};\n\nfunction RollingText({ text, reduced }: { text: string; reduced: boolean }) {\n  return <span className={styles.roll}>\n    <span className={styles.srOnly}>{text}</span>\n    <span aria-hidden=\"true\"><AnimatePresence mode=\"popLayout\" initial={false}>\n      {text.split(/(\\s+)/).map((word, index) => <motion.span key={`${index}:${word}`} className={styles.word} variants={wordMotion} initial={reduced ? false : \"enter\"} animate=\"center\" exit={reduced ? undefined : \"exit\"}>{word}</motion.span>)}\n    </AnimatePresence></span>\n  </span>;\n}\n\nexport function ExpandableCard({ title, description, children, defaultExpanded = false, width, expandedWidth, className, style, onKeyDown, ...rest }: 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) { placed.current = true; boxWidthValue.jump(boxWidth); return; }\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.\n  const capVars = { \"--expandable-card-width\": width ? `${width}px` : undefined, \"--expandable-card-expanded-width\": openCap ? `${openCap}px` : undefined } 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  return <div ref={trackRef} className={styles.track}>\n    <motion.article\n      {...rest}\n      className={[styles.card, className].filter(Boolean).join(\" \")}\n      data-expanded={expanded}\n      data-measured={room === null ? undefined : \"\"}\n      style={{ ...style, ...capVars, width: boxWidthValue }}\n      onKeyDown={handleKeyDown}\n    >\n      <button ref={triggerRef} type=\"button\" className={styles.trigger} aria-expanded={expanded} aria-controls={panelId} onClick={() => toggle(!expanded)}>\n        <span className={styles.copy}><strong>{title}</strong>{description && <span className={styles.description}><RollingText text={description} reduced={reduceMotion} /></span>}</span>\n        <motion.span className={styles.arrow} initial={false} animate={{ rotate: expanded ? 180 : 0 }} transition={reduceMotion ? still : boxTransition(expanded, hold)}><NavArrowDown width={18} height={18} aria-hidden=\"true\" /></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      <motion.div id={panelId} className={styles.panel} inert={!expanded} initial={false} animate={{ height: expanded ? \"auto\" : 0 }} transition={reduceMotion ? still : boxTransition(expanded, hold)}>\n        <motion.div className={styles.panelInner} style={innerWidth === undefined ? undefined : { width: innerWidth - 2 }} initial={false} animate={{ opacity: expanded ? 1 : 0 }} transition={reduceMotion ? still : contentTransition(expanded)}>{children}</motion.div>\n      </motion.div>\n    </motion.article>\n  </div>;\n}\n\nexport default ExpandableCard;\n"
    },
    {
      "path": "registry/components/expandable-card/expandable-card.module.css",
      "type": "registry:component",
      "target": "@components/xzero/expandable-card/expandable-card.module.css",
      "content": "/* The track centers the card and measures the room it may grow into. */\n.track { display: flex; width: 100%; min-width: 0; justify-content: center; }\n.card { width: 100%; max-width: var(--expandable-card-width, 100%); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface); box-shadow: var(--shadow-resting); }\n.card[data-expanded=\"true\"] { max-width: var(--expandable-card-expanded-width, var(--expandable-card-width, 100%)); }\n/* Once measured, the width is animated in px, so the CSS cap steps aside and never clamps the morph. */\n.card[data-measured] { max-width: 100%; }\n/* The card clips the header to its corners, so the header's hover fill never changes shape while the card opens or closes. */\n.trigger { display: flex; width: 100%; min-height: 64px; align-items: center; justify-content: space-between; gap: var(--space-5); border: 0; padding: var(--space-4) var(--space-5); background: transparent; color: var(--foreground); font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .trigger:hover { background: var(--surface-muted); } .trigger:hover .arrow { color: var(--foreground); } }\n.trigger:active { background: var(--surface-muted); }\n.copy { min-width: 0; }\n.copy strong { display: block; font-size: var(--text-sm); font-weight: 500; }\n.description { display: block; margin-top: 4px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.roll { position: relative; display: block; }\n.word { display: inline-block; white-space: pre; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.arrow { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }\n.card[data-expanded=\"true\"] .arrow { color: var(--foreground); }\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.panel { overflow: hidden; box-shadow: inset 0 1px 0 var(--border); background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-sm); }\n.panelInner { max-width: none; padding: var(--space-4) var(--space-5) var(--space-5); }\n@media (prefers-reduced-motion: reduce) { .trigger, .arrow { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/expandable-card",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "disclosure",
      "layout"
    ],
    "docs": "https://ui.xzero.co.uk/components/expandable-card",
    "markdown": "https://ui.xzero.co.uk/components/expandable-card/markdown"
  }
}