{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-text",
  "type": "registry:ui",
  "title": "Expandable text",
  "description": "Long text clamped to a few lines with a soft fade that glides open with Read more and closes with Show less, shown only when the text overflows.",
  "categories": [
    "disclosure"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/components/expandable-text/expandable-text.tsx",
      "type": "registry:component",
      "target": "@components/xzero/expandable-text/expandable-text.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, FocusEvent, HTMLAttributes, ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { AnimationPlaybackControls } from \"motion/react\";\nimport styles from \"./expandable-text.module.css\";\n\n/**\n * Long text clamped to a few lines with a soft fade over the last one. Read more opens it to its full height with a calm ease out,\n * and Show less closes it again, bringing the start of the text back into view when it had scrolled away. The toggle only appears when the text\n * really overflows its lines, and stays correct as the width or font changes.\n */\nexport interface ExpandableTextProps extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The text. Plain strings, paragraphs, and inline links all work; the whole text stays available to screen readers while clamped. */\n  children: ReactNode;\n  /** How many lines show while collapsed. */\n  lines?: number;\n  /** Controlled open state. */\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Toggle label while collapsed. */\n  moreLabel?: string;\n  /** Toggle label while open. */\n  lessLabel?: string;\n}\n\nconst noop = () => () => {};\n/** A soft ease out: it answers the click at once, then spends most of its time settling, so a long paragraph glides open instead of snapping. */\nconst ease = [0.32, 0.5, 0.2, 1] as const;\n/** Opening takes 500 to 600ms and closing 400 to 450ms, growing gently with the distance the text travels. */\nconst openDuration = (distance: number) => 0.5 + Math.min(distance / 1600, 0.1);\nconst closeDuration = (distance: number) => 0.4 + Math.min(distance / 2000, 0.05);\n\n/** Reduced motion only counts after hydration, so the server and the first client render agree. */\nfunction useReduced() {\n  const hydrated = useSyncExternalStore(noop, () => true, () => false);\n  const reduced = useReducedMotion() ?? false;\n  return hydrated && reduced;\n}\n\nexport function ExpandableText({ children, lines = 3, expanded: expandedProp, defaultExpanded = false, onExpandedChange, moreLabel = \"Read more\", lessLabel = \"Show less\", className, style, ...props }: ExpandableTextProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultExpanded);\n  const expanded = expandedProp ?? uncontrolled;\n  // Long text is the common case, so the server renders the toggle and the first measurement removes it when the text fits.\n  const [overflowing, setOverflowing] = useState(true);\n  const [animating, setAnimating] = useState(false);\n  // The state the DOM has settled in. The clamp only changes once a move ends, so the height can be read before anything lifts.\n  const [settled, setSettled] = useState(expanded);\n  const reduced = useReduced();\n  const contentId = useId();\n  const root = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const inner = useRef<HTMLDivElement>(null);\n  const probe = useRef<HTMLSpanElement>(null);\n  const clampHeight = useRef(0);\n  const range = useRef({ from: 0, to: 0 });\n  const running = useRef<AnimationPlaybackControls | null>(null);\n  const lastExpanded = useRef(expanded);\n  /** 0 is clamped, 1 is fully open. Height, the fade, and the chevron all follow this one value, so they always move in step. */\n  const progress = useMotionValue(expanded ? 1 : 0);\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const rotate = useTransform(progress, [0, 1], [0, 180]);\n  // The fade eases away as the text opens and only returns as it nears the clamp again. While the height moves, newly revealed lines\n  // rise through the soft edge instead of being uncovered by a hard line.\n  const maskImage = useTransform(progress, value => {\n    const fade = Math.pow(1 - Math.min(Math.max(value, 0), 1), 1.35);\n    return fade <= 0.002 ? \"none\" : `linear-gradient(to bottom, #000 calc(100% - 1.6lh), rgb(0 0 0 / ${(1 - fade * 0.96).toFixed(3)}) 100%)`;\n  });\n\n  useEffect(() => progress.on(\"change\", value => {\n    if (!running.current) return;\n    const { from, to } = range.current;\n    height.set(from + (to - from) * value);\n  }), [progress, height]);\n\n  // The probe is exactly `lines` lines tall in the text's own line height, so fonts, sizes, and wrapping never need to be computed by hand.\n  useLayoutEffect(() => {\n    const probeNode = probe.current, innerNode = inner.current;\n    if (!probeNode || !innerNode) return;\n    const measure = () => {\n      clampHeight.current = probeNode.getBoundingClientRect().height;\n      setOverflowing(innerNode.getBoundingClientRect().height > clampHeight.current + 1);\n    };\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(measure);\n    observer.observe(probeNode);\n    observer.observe(innerNode);\n    return () => observer.disconnect();\n  }, [lines]);\n\n  // Animate whenever the open state changes, from the toggle or a controlled prop. A change mid-flight carries on from the current height.\n  useLayoutEffect(() => {\n    if (lastExpanded.current === expanded) return;\n    lastExpanded.current = expanded;\n    const node = content.current, innerNode = inner.current;\n    running.current?.stop();\n    running.current = null;\n    if (!node || !innerNode || reduced) { progress.set(expanded ? 1 : 0); height.set(\"auto\"); setAnimating(false); setSettled(expanded); return; }\n    const full = innerNode.getBoundingClientRect().height;\n    const clamp = Math.min(clampHeight.current, full);\n    const current = node.getBoundingClientRect().height;\n    // Progress maps the clamp to 0 and the full height to 1; resync it to the real height in case the text reflowed since the last move.\n    range.current = { from: clamp, to: full };\n    const span = Math.max(full - clamp, 1);\n    progress.jump(Math.min(Math.max((current - clamp) / span, 0), 1));\n    // Pin the current height before the clamp lifts, so nothing jumps for a frame.\n    node.style.height = `${current}px`;\n    height.set(current);\n    setAnimating(true);\n    const target = expanded ? 1 : 0;\n    const distance = Math.abs(target - progress.get()) * span;\n    running.current = animate(progress, target, {\n      duration: expanded ? openDuration(distance) : closeDuration(distance),\n      ease: [...ease],\n      onComplete: () => { running.current = null; height.set(\"auto\"); setAnimating(false); setSettled(expanded); },\n    });\n  }, [expanded, reduced, progress, height]);\n\n  useEffect(() => () => running.current?.stop(), []);\n\n  const setExpanded = (next: boolean) => {\n    if (next === expanded) return;\n    // Closing from far down the page would leave the reader below the text; bring its start back into view as it closes.\n    const node = root.current;\n    if (!next && node && node.getBoundingClientRect().top < 0) node.scrollIntoView({ block: \"start\", behavior: reduced ? \"auto\" : \"smooth\" });\n    if (expandedProp === undefined) setUncontrolled(next);\n    onExpandedChange?.(next);\n  };\n\n  // A link or button hidden below the clamp opens the text when it receives keyboard focus.\n  const onFocusCapture = (event: FocusEvent<HTMLDivElement>) => {\n    props.onFocusCapture?.(event);\n    if (expanded || !overflowing || event.target === content.current || !content.current?.contains(event.target)) return;\n    const box = content.current?.getBoundingClientRect(), target = event.target.getBoundingClientRect();\n    if (box && target.bottom > box.bottom + 1) setExpanded(true);\n  };\n\n  const clamped = !settled && !animating && overflowing;\n  const masked = overflowing && (!settled || animating);\n  return <div {...props} ref={root} onFocusCapture={onFocusCapture} className={[styles.root, className].filter(Boolean).join(\" \")} style={{ ...style, \"--expandable-text-lines\": lines } as CSSProperties}>\n    <motion.div\n      ref={content}\n      id={contentId}\n      className={styles.content}\n      data-clamped={clamped ? \"\" : undefined}\n      style={{ height, WebkitMaskImage: masked ? maskImage : \"none\", maskImage: masked ? maskImage : \"none\" }}\n    >\n      <span ref={probe} className={styles.probe} aria-hidden=\"true\" />\n      <div ref={inner} className={styles.inner}>{children}</div>\n    </motion.div>\n    {overflowing && <button type=\"button\" className={styles.toggle} aria-expanded={expanded} aria-controls={contentId} onClick={() => setExpanded(!expanded)}>\n      {/* Both labels share one grid cell, so the button keeps the wider width and the chevron never jumps as they crossfade. */}\n      <span className={styles.labels}>\n        <span className={styles.label} data-active={!expanded || undefined} aria-hidden={expanded || undefined}>{moreLabel}</span>\n        <span className={styles.label} data-active={expanded || undefined} aria-hidden={!expanded || undefined}>{lessLabel}</span>\n      </span>\n      <motion.span className={styles.icon} aria-hidden=\"true\" style={{ rotate }}>\n        <ChevronDown width={16} height={16} strokeWidth={1.75} />\n      </motion.span>\n    </button>}\n  </div>;\n}\n\nexport default ExpandableText;\n"
    },
    {
      "path": "registry/components/expandable-text/expandable-text.module.css",
      "type": "registry:component",
      "target": "@components/xzero/expandable-text/expandable-text.module.css",
      "content": ".root { --expandable-text-lines: 3; display: grid; justify-items: start; gap: var(--space-2); min-width: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); }\n/* Height is owned by motion while it moves; at rest the clamp is plain CSS, so it follows every width and font change for free. */\n.content { position: relative; width: 100%; min-width: 0; overflow: hidden; overflow-wrap: anywhere; }\n.content[data-clamped] { max-height: calc(var(--expandable-text-lines) * 1.4em); max-height: calc(var(--expandable-text-lines) * 1lh); }\n/* Exactly `lines` lines tall in the text's own line height; measured to decide whether the toggle is needed. */\n.probe { position: absolute; top: 0; left: 0; width: 0; height: calc(var(--expandable-text-lines) * 1.4em); height: calc(var(--expandable-text-lines) * 1lh); visibility: hidden; pointer-events: none; }\n.inner > :first-child { margin-top: 0; }\n.inner > :last-child { margin-bottom: 0; }\n.inner p { margin: 0 0 .75em; }\n.toggle { display: inline-flex; align-items: center; gap: var(--space-1); min-height: 28px; margin: 0 0 0 -2px; padding: 0 2px; border: 0; border-radius: 8px; background: transparent; color: var(--foreground); font: inherit; font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .toggle:hover { color: var(--text-secondary); } }\n.toggle:active { color: var(--text-muted); }\n.labels { display: inline-grid; justify-items: start; }\n/* The labels crossfade with a short vertical drift and a brief blur, timed to the opening. */\n.label { grid-area: 1 / 1; opacity: 0; filter: blur(2px); transform: translateY(-4px); transition: opacity var(--duration-standard) var(--ease-standard), transform var(--duration-considered) var(--ease-standard), filter var(--duration-standard) var(--ease-standard); pointer-events: none; }\n.label[data-active] { opacity: 1; filter: blur(0); transform: none; }\n.label + .label:not([data-active]) { transform: translateY(4px); }\n.icon { display: inline-flex; color: var(--text-muted); }\n@media (prefers-reduced-motion: reduce) { .toggle, .label { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/expandable-text",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "disclosure",
      "text",
      "motion",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/expandable-text",
    "markdown": "https://ui.xzero.co.uk/components/expandable-text/markdown"
  }
}