{
  "$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": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 { cn } from \"../lib/utils\";\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/** The labels crossfade with a short vertical drift and a brief blur, timed to the opening. The incoming label rises from below, the outgoing one lifts away. */\nconst label =\n  \"pointer-events-none [grid-area:1/1] [filter:blur(2px)] opacity-0 [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)] data-active:[filter:blur(0)] data-active:opacity-100 data-active:[transform:none] motion-reduce:transition-none\";\n\n/** Reduced motion only counts after hydration, so the server and the first client render agree. */\nfunction useReduced() {\n  const hydrated = useSyncExternalStore(\n    noop,\n    () => true,\n    () => false,\n  );\n  const reduced = useReducedMotion() ?? false;\n  return hydrated && reduced;\n}\n\nexport function ExpandableText({\n  children,\n  lines = 3,\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  moreLabel = \"Read more\",\n  lessLabel = \"Show less\",\n  className,\n  style,\n  ...props\n}: 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\n      ? \"none\"\n      : `linear-gradient(to bottom, #000 calc(100% - 1.6lh), rgb(0 0 0 / ${(1 - fade * 0.96).toFixed(3)}) 100%)`;\n  });\n\n  useEffect(\n    () =>\n      progress.on(\"change\", (value) => {\n        if (!running.current) return;\n        const { from, to } = range.current;\n        height.set(from + (to - from) * value);\n      }),\n    [progress, height],\n  );\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,\n      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,\n      innerNode = inner.current;\n    running.current?.stop();\n    running.current = null;\n    if (!node || !innerNode || reduced) {\n      progress.set(expanded ? 1 : 0);\n      height.set(\"auto\");\n      setAnimating(false);\n      setSettled(expanded);\n      return;\n    }\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: () => {\n        running.current = null;\n        height.set(\"auto\");\n        setAnimating(false);\n        setSettled(expanded);\n      },\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)\n      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))\n      return;\n    const box = content.current?.getBoundingClientRect(),\n      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 (\n    <div\n      {...props}\n      ref={root}\n      onFocusCapture={onFocusCapture}\n      className={cn(\"grid min-w-0 justify-items-start gap-2 text-sm leading-body tracking-body text-ink-2\", className)}\n      style={{ ...style, \"--expandable-text-lines\": lines } as CSSProperties}\n    >\n      <motion.div\n        ref={content}\n        id={contentId}\n        className=\"relative w-full min-w-0 overflow-hidden wrap-anywhere data-clamped:max-h-[calc(var(--expandable-text-lines)_*_1lh)]\"\n        data-clamped={clamped ? \"\" : undefined}\n        style={{ height, WebkitMaskImage: masked ? maskImage : \"none\", maskImage: masked ? maskImage : \"none\" }}\n      >\n        {/* Exactly `lines` lines tall in the text's own line height; measured to decide whether the toggle is needed. */}\n        <span\n          ref={probe}\n          className=\"pointer-events-none invisible absolute top-0 left-0 h-[calc(var(--expandable-text-lines)_*_1lh)] w-0\"\n          aria-hidden=\"true\"\n        />\n        <div ref={inner} className=\"[&_p]:m-0 [&_p]:mb-[.75em] [&>:first-child]:mt-0 [&>:last-child]:mb-0\">\n          {children}\n        </div>\n      </motion.div>\n      {overflowing && (\n        <button\n          type=\"button\"\n          className=\"-ml-0.5 inline-flex min-h-7 cursor-pointer items-center gap-1 rounded-button border-0 bg-transparent px-0.5 font-medium text-ink [-webkit-tap-highlight-color:transparent] transition-[color] duration-fast ease-standard hover:text-ink-2 active:text-ink-3 motion-reduce:transition-none\"\n          aria-expanded={expanded}\n          aria-controls={contentId}\n          onClick={() => setExpanded(!expanded)}\n        >\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=\"inline-grid justify-items-start\">\n            <span className={label} data-active={!expanded || undefined} aria-hidden={expanded || undefined}>\n              {moreLabel}\n            </span>\n            <span\n              className={cn(label, \"not-data-active:[transform:translateY(4px)]\")}\n              data-active={expanded || undefined}\n              aria-hidden={!expanded || undefined}\n            >\n              {lessLabel}\n            </span>\n          </span>\n          <motion.span className=\"inline-flex text-ink-3\" aria-hidden=\"true\" style={{ rotate }}>\n            <ChevronDown width={16} height={16} strokeWidth={1.75} />\n          </motion.span>\n        </button>\n      )}\n    </div>\n  );\n}\n\nexport default ExpandableText;\n",
      "type": "registry:component",
      "target": "@components/iris/expandable-text/expandable-text.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/expandable-text",
  "meta": {
    "kind": "component",
    "group": "Overlays & disclosure",
    "export": "ExpandableText",
    "tags": [
      "disclosure",
      "text",
      "motion",
      "new"
    ],
    "docs": "https://irisui.dev/components/expandable-text"
  }
}
