{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/expandable-card/expandable-card.tsx","type":"registry:component","target":"@components/arc/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, \"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 \n {text}\n \n {text.split(/(\\s+)/).map((word, index) => {word})}\n \n ;\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(null);\n const trackRef = useRef(null);\n const triggerRef = useRef(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(\"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) => {\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
\n \n \n {/* The panel stays mounted so a second click mid-animation reverses from where it is; inert keeps closed details out of reach. */}\n \n {children}\n \n \n
;\n}\n\nexport default ExpandableCard;\n"},{"path":"registry/components/expandable-card/expandable-card.module.css","type":"registry:component","target":"@components/arc/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://uiarc.dev/components/expandable-card","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/expandable-card","markdown":"https://uiarc.dev/components/expandable-card/markdown","tags":["disclosure","layout"]}}