{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "read-more",
  "title": "Read More",
  "description": "Long text clamped to a few lines with a Show more / Show less toggle that appears only when the text is genuinely too long. Use it wherever text is usually short but occasionally is not: product and marketplace listing descriptions, comments, reviews and replies, user bios and profile blurbs, release notes and changelog entries, incident and error detail, log lines, AI answers and summaries, job posts, FAQ answers, and long cells in a card or table. Common asks it answers: \"read more button\", \"show more / show less\", \"expandable text\", \"truncate text with a show more link\", \"line clamp with toggle\", \"collapsible paragraph\", \"see more link\", \"clamp description to 3 lines\", \"react-show-more-text alternative\", \"text truncation with expand\". shadcn/ui ships nothing for this, and its collapsible is a different thing — a generic open/close container whose trigger is always there and which does no clamping — so the genuinely awkward part is left to you: deciding whether the toggle should exist at all. This measures the rendered text and renders the control only when the clamped box actually overflows, so a list of mostly-short entries does not sprout a pointless \"Show more\" under every one of them. It re-measures when the column resizes and the text rewraps, and again once web fonts have loaded, because a clamped box keeps its height while the line count underneath it changes; an element that is off screen in a closed tab or accordion measures zero, which it treats as \"unknown\" rather than \"it fits\", so the toggle is not dropped while the text is out of view. The clamp is applied as inline style rather than Tailwind's line-clamp-N utility, because `lines` is a runtime value and a dynamic `line-clamp-${n}` class is invisible to Tailwind's scanner — it would work in dev and silently vanish from the production build. Accessibility is where the hand-rolled version usually goes wrong: the full text always stays in the DOM and is only clipped visually, so screen readers read all of it and find-in-page still reaches it, instead of the usual text.slice(0, 200) that destroys the content for everybody; the control is a real button carrying aria-expanded and aria-controls pointing at the text. Clipped is not hidden, so a link inside the invisible part is still in the tab order — focus landing there expands the block rather than letting the browser scroll the clamped box and shear the text mid-line. Collapsing pulls the block back into view when it has already scrolled off the top, so the reader is not dumped further down the page. Uncontrolled by default; pass expanded and onExpandedChange to drive it from an \"expand all\" control. Styled with shadcn tokens (ring, muted-foreground) so it follows light and dark themes, and ships with no dependencies beyond your own cn util.",
  "files": [
    {
      "path": "registry/ui/read-more.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface ReadMoreProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Lines to show while collapsed. Anything below 1 is treated as 1. Defaults to 3. */\n  lines?: number\n  /** Label on the control that expands the text. Defaults to \"Show more\". */\n  moreLabel?: string\n  /** Label on the control that collapses it again. Defaults to \"Show less\". */\n  lessLabel?: string\n  /** Start expanded. Uncontrolled use only. */\n  defaultExpanded?: boolean\n  /** Controlled expansion — pair with onExpandedChange. */\n  expanded?: boolean\n  onExpandedChange?: (expanded: boolean) => void\n}\n\n// useLayoutEffect measures before paint so the toggle never flashes in or out, but it warns\n// during SSR — fall back to useEffect on the server.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\n/**\n * A clamp of 0 hides the text completely and a fractional or NaN clamp means nothing to\n * -webkit-line-clamp, so a caller passing something odd gets the smallest sane clamp rather\n * than an empty box with a \"Show more\" under it.\n */\nfunction normalizeLines(lines: number | undefined): number {\n  const n = Number(lines)\n  return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1\n}\n\n/**\n * Whether the clamped text is taller than the box showing it.\n *\n * A hidden element — inside a closed tab, accordion, or an unopened dialog — measures 0×0,\n * which is not the same answer as \"it fits\". Keeping the previous answer stops the toggle\n * from being dropped while the text is off screen and never coming back.\n *\n * The 1px tolerance absorbs sub-pixel line heights, which otherwise report a few tenths of a\n * pixel of overflow on text that visually fits exactly.\n */\nfunction decideOverflow(\n  previous: boolean,\n  scrollHeight: number,\n  clientHeight: number\n): boolean {\n  if (clientHeight <= 0) return previous\n  return scrollHeight - clientHeight > 1\n}\n\n// Written as inline style rather than Tailwind's line-clamp-N: the clamp is a runtime number,\n// and `line-clamp-${lines}` is invisible to Tailwind's scanner, so it survives dev and\n// silently disappears from the production build.\n//\n// The clamp is passed as a string on purpose. React appends \"px\" to numeric style values\n// unless the property is on its unitless list, and `-webkit-line-clamp: 3px` is invalid: the\n// clamp would stop applying, the box would then report no overflow, and the toggle would\n// vanish along with it — a component that quietly does nothing. A string cannot be given a unit.\nfunction clampStyle(lines: number): React.CSSProperties {\n  return {\n    display: \"-webkit-box\",\n    WebkitBoxOrient: \"vertical\",\n    WebkitLineClamp: String(lines),\n    overflow: \"hidden\",\n  }\n}\n\nconst CLAMP_CSS_PROPERTIES = [\n  \"display\",\n  \"-webkit-box-orient\",\n  \"-webkit-line-clamp\",\n  \"overflow\",\n] as const\n\n/**\n * Long text clamped to a few lines, with a Show more / Show less toggle that only appears\n * when the text actually overflows. Use it for product and listing descriptions, comments\n * and reviews, bios, release notes, log or error detail, and AI answers — anywhere the text\n * is usually short but occasionally long enough to push the rest of the page away.\n *\n * The whole text stays in the DOM and is only clipped visually, so screen readers read all\n * of it either way and the browser can still find it with ⌘F.\n */\nexport const ReadMore = React.forwardRef<HTMLDivElement, ReadMoreProps>(\n  function ReadMore(\n    {\n      className,\n      children,\n      lines = 3,\n      moreLabel = \"Show more\",\n      lessLabel = \"Show less\",\n      defaultExpanded = false,\n      expanded,\n      onExpandedChange,\n      ...props\n    },\n    forwardedRef\n  ) {\n    const rootRef = React.useRef<HTMLDivElement>(null)\n    const textRef = React.useRef<HTMLDivElement>(null)\n    React.useImperativeHandle(forwardedRef, () => rootRef.current as HTMLDivElement)\n\n    const textId = React.useId()\n    const lineCount = normalizeLines(lines)\n\n    const [uncontrolled, setUncontrolled] = React.useState(defaultExpanded)\n    const isControlled = expanded !== undefined\n    const isExpanded = isControlled ? expanded : uncontrolled\n\n    // False until measured. The server cannot measure, and the first client render has to\n    // match what the server sent, so the toggle is added by the layout effect below —\n    // before paint, so it is never visibly missing.\n    const [overflowing, setOverflowing] = React.useState(false)\n\n    const measure = React.useCallback(() => {\n      const el = textRef.current\n      if (!el) return\n\n      // While expanded there is no clamp to measure against, so put one on just long enough\n      // to read the two heights. This runs inside a layout effect (or a ResizeObserver\n      // callback), so the styles are gone again before anything is painted.\n      const needsTemporaryClamp = isExpanded\n      if (needsTemporaryClamp) {\n        el.style.setProperty(\"display\", \"-webkit-box\")\n        el.style.setProperty(\"-webkit-box-orient\", \"vertical\")\n        el.style.setProperty(\"-webkit-line-clamp\", String(lineCount))\n        el.style.setProperty(\"overflow\", \"hidden\")\n      }\n\n      const { scrollHeight, clientHeight } = el\n\n      // Safe to remove rather than restore: React only writes these four while collapsed,\n      // and this branch runs only while expanded.\n      if (needsTemporaryClamp) {\n        for (const property of CLAMP_CSS_PROPERTIES) el.style.removeProperty(property)\n      }\n\n      setOverflowing((previous) => decideOverflow(previous, scrollHeight, clientHeight))\n    }, [isExpanded, lineCount])\n\n    // Re-measure on mount and whenever the text or the clamp changes.\n    useIsomorphicLayoutEffect(measure, [measure, children])\n\n    // A narrower column rewraps the text, which changes how many lines it needs.\n    React.useEffect(() => {\n      const el = textRef.current\n      if (!el || typeof ResizeObserver === \"undefined\") return\n      const observer = new ResizeObserver(() => measure())\n      observer.observe(el)\n      return () => observer.disconnect()\n    }, [measure])\n\n    // A web font swapping in changes the line count without changing the box: while clamped,\n    // the height is `lines × line-height`, which Tailwind pins, so the resize observer above\n    // never fires and the answer would stay stale at whatever the fallback font needed.\n    React.useEffect(() => {\n      if (typeof document === \"undefined\" || !document.fonts) return\n      let cancelled = false\n      document.fonts.ready.then(() => {\n        if (!cancelled) measure()\n      })\n      return () => {\n        cancelled = true\n      }\n    }, [measure])\n\n    const changeExpanded = React.useCallback(\n      (next: boolean) => {\n        if (!isControlled) setUncontrolled(next)\n        onExpandedChange?.(next)\n      },\n      [isControlled, onExpandedChange]\n    )\n\n    // Collapsing removes height above the fold, so a reader who expanded, scrolled down and\n    // collapsed again would be dropped somewhere further down the page. Pull the block back\n    // into view instead — but only when it has actually scrolled off the top.\n    const restoreScrollRef = React.useRef(false)\n    useIsomorphicLayoutEffect(() => {\n      if (!restoreScrollRef.current) return\n      restoreScrollRef.current = false\n      const el = rootRef.current\n      if (!el || isExpanded) return\n      if (el.getBoundingClientRect().top < 0) el.scrollIntoView({ block: \"start\" })\n    }, [isExpanded])\n\n    function handleToggle() {\n      if (isExpanded) restoreScrollRef.current = true\n      changeExpanded(!isExpanded)\n    }\n\n    function handleFocusCapture(event: React.FocusEvent<HTMLDivElement>) {\n      // Clipped is not hidden: a link in the part nobody can see is still in the tab order,\n      // and the browser scrolls the clipped box to chase it. Reveal the text instead.\n      if (isExpanded || event.target === event.currentTarget) return\n      changeExpanded(true)\n    }\n\n    function handleScroll(event: React.UIEvent<HTMLDivElement>) {\n      // Belt and braces for the same problem: a controlled caller may decline to expand, and\n      // a clamp scrolled down by even a few pixels shows the text sheared mid-line.\n      event.currentTarget.scrollTop = 0\n    }\n\n    return (\n      <div ref={rootRef} className={cn(\"space-y-1\", className)} {...props}>\n        <div\n          id={textId}\n          ref={textRef}\n          style={isExpanded ? undefined : clampStyle(lineCount)}\n          onFocusCapture={handleFocusCapture}\n          onScroll={handleScroll}\n        >\n          {children}\n        </div>\n        {overflowing ? (\n          <button\n            type=\"button\"\n            onClick={handleToggle}\n            aria-expanded={isExpanded}\n            aria-controls={textId}\n            className=\"rounded-sm text-sm font-medium underline underline-offset-4 hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n          >\n            {isExpanded ? lessLabel : moreLabel}\n          </button>\n        ) : null}\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}