{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "middle-truncate",
  "title": "Middle Truncate",
  "description": "One line of text with the middle removed so that both ends stay readable, fitted to whatever width the container actually gives it. Use it for file names — where ordinary CSS truncation eats the extension and every row ends up reading \"quarterly-report-2026-fin…\" — and for file paths, URLs, S3 and storage keys, git SHAs and commit hashes, wallet and contract addresses, API keys and tokens, request, trace and session IDs, branch names, and any other identifier whose tail is the part that tells two of them apart. Common asks it answers: \"truncate the middle of a string\", \"middle ellipsis\", \"truncate a filename but keep the extension\", \"ellipsis in the middle of text\", \"shorten a wallet address to 0x1234…abcd\", \"truncate a long path from the middle\", \"text-overflow ellipsis but centered\", \"abbreviate a long ID\", \"react-middle-truncate alternative\". CSS cannot do this — text-overflow: ellipsis only ever cuts the end — and shadcn/ui ships nothing for it, so it is normally hand-rolled as a fixed character count that is wrong at every container width except the one it was tuned for. This measures the rendered text against the box it has to fit and binary-searches the cut point, so it fills the space exactly; it re-measures when the column resizes and again once web fonts have loaded, because a font swap changes every glyph width without changing the box. It cuts on grapheme boundaries using Intl.Segmenter, so an emoji, flag or accented letter landing on the cut does not become a replacement glyph the way a raw slice() would — which matters more here than elsewhere, since the cut point moves every time the container resizes. The full string stays in the DOM and only the visible copy is shortened: screen readers get the whole value instead of \"0x4f2a ellipsis 91bc\", find-in-page still matches it, and selecting the line copies the full text exactly once rather than the shortened form. Hovering shows the full value as a tooltip. It takes its width from its container — a flex row, a grid track, or a fixed width — and needs no min-w-0 to shrink; inside a shrink-to-fit parent there is nothing to fit to, so it simply renders in full.",
  "files": [
    {
      "path": "registry/ui/middle-truncate.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface MiddleTruncateProps\n  extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\"> {\n  /** The full string. This is what is announced, copied, and found by find-in-page. */\n  text: string\n  /** Share of the surviving characters kept on the left, 0–1. Defaults to 0.5. */\n  ratio?: number\n  /** Marker placed between the two halves. Defaults to \"…\". */\n  ellipsis?: string\n}\n\n// useLayoutEffect measures before paint, so the full string is never briefly visible before\n// collapsing to its truncated form. It warns during SSR, where there is nothing to measure.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\n// Sub-pixel slack. Text widths and content-box widths are both fractional, and a string that\n// fills its box exactly can report a few hundredths of a pixel of overflow. Kept well under a\n// pixel on purpose: over-generous slack shaves the last glyph, which is the whole point of the\n// component.\nconst WIDTH_TOLERANCE = 0.5\n\n// The probe is absolutely positioned, so it takes part in no layout and contributes nothing to\n// the parent's width; `visibility: hidden` keeps it off screen while still being measurable\n// (`display: none` would report zero). It inherits font, weight, letter-spacing and\n// text-transform from the root, so it measures the text exactly as the visible span renders it.\nconst PROBE_STYLE: React.CSSProperties = {\n  position: \"absolute\",\n  top: 0,\n  left: 0,\n  visibility: \"hidden\",\n  pointerEvents: \"none\",\n  whiteSpace: \"nowrap\",\n  width: \"auto\",\n  maxWidth: \"none\",\n  userSelect: \"none\",\n}\n\ntype GraphemeSegmenter = { segment(input: string): Iterable<{ segment: string }> }\ntype SegmenterConstructor = new (\n  locales: undefined,\n  options: { granularity: \"grapheme\" }\n) => GraphemeSegmenter\n\n/**\n * Split into user-perceived characters rather than UTF-16 code units.\n *\n * Slicing a raw string cuts through surrogate pairs and combining marks, so an emoji, a flag,\n * or an accented letter landing on the cut turns into a replacement glyph. A truncation\n * component cannot shrug that off the way ordinary code can: the cut point moves every time\n * the container resizes, so the corruption appears and disappears at arbitrary widths.\n */\nfunction toGraphemes(text: string): string[] {\n  const Segmenter = (Intl as unknown as { Segmenter?: SegmenterConstructor }).Segmenter\n  if (typeof Segmenter === \"function\") {\n    return Array.from(\n      new Segmenter(undefined, { granularity: \"grapheme\" }).segment(text),\n      (entry) => entry.segment\n    )\n  }\n  // Older engines: iterating a string yields code points, which keeps surrogate pairs whole\n  // even though it still splits combining marks.\n  return Array.from(text)\n}\n\n/**\n * The finite check is the part that matters: `Math.round(kept * NaN)` is NaN, which makes every\n * candidate collapse to a bare ellipsis, so a box with room for twenty characters would render\n * \"…x\" — a component that quietly does nothing.\n *\n * The 0–1 clamp only keeps the value inside its documented range; out-of-range ratios are\n * already absorbed downstream, where the head is clamped against the number of survivors.\n */\nfunction normalizeRatio(ratio: number | undefined): number {\n  const value = Number(ratio)\n  if (!Number.isFinite(value)) return 0.5\n  return Math.min(Math.max(value, 0), 1)\n}\n\n/**\n * Build the candidate that keeps `kept` graphemes, split either side of the ellipsis.\n *\n * Both sides are held to at least one grapheme once there is room for two, because \"…d1f9c2\"\n * and \"a3b7e0…\" are each just an end-truncation wearing a different hat — the caller asked for\n * this component precisely because both ends carry meaning.\n */\nfunction buildCandidate(\n  graphemes: string[],\n  kept: number,\n  ratio: number,\n  ellipsis: string\n): string {\n  if (kept <= 0) return ellipsis\n  // One survivor goes to the tail: extensions, checksums and trailing path segments are the\n  // half a reader can least afford to lose.\n  if (kept === 1) return ellipsis + graphemes[graphemes.length - 1]\n\n  const head = Math.min(Math.max(Math.round(kept * ratio), 1), kept - 1)\n  const tail = kept - head\n  return (\n    graphemes.slice(0, head).join(\"\") +\n    ellipsis +\n    graphemes.slice(graphemes.length - tail).join(\"\")\n  )\n}\n\n/**\n * One line of text with the middle removed so that both ends stay visible, sized to whatever\n * width the container actually gives it. Use it for file names, where CSS truncation eats the\n * extension; and for paths, URLs, S3 and storage keys, git SHAs, wallet and contract addresses,\n * API keys, request and trace IDs, branch names, and any other identifier whose tail is what\n * tells two of them apart.\n *\n * The full string stays in the DOM for screen readers, clipboard and ⌘F; only the visible copy\n * is shortened.\n */\nexport const MiddleTruncate = React.forwardRef<HTMLSpanElement, MiddleTruncateProps>(\n  function MiddleTruncate(\n    { text, ratio = 0.5, ellipsis = \"…\", className, ...props },\n    forwardedRef\n  ) {\n    const rootRef = React.useRef<HTMLSpanElement>(null)\n    const probeRef = React.useRef<HTMLSpanElement>(null)\n    React.useImperativeHandle(forwardedRef, () => rootRef.current as HTMLSpanElement)\n\n    // null means \"show the whole string\". The server cannot measure and the first client render\n    // has to match it, so every render starts here and the layout effect below narrows it\n    // before paint.\n    const [truncated, setTruncated] = React.useState<string | null>(null)\n\n    const safeRatio = normalizeRatio(ratio)\n    const graphemes = React.useMemo(() => toGraphemes(text), [text])\n\n    const measure = React.useCallback(() => {\n      const root = rootRef.current\n      const probe = probeRef.current\n      if (!root || !probe) return\n\n      // clientWidth includes padding, so a caller adding `px-3` would otherwise get a string\n      // measured against a box wider than the one it has to fit in.\n      const style = window.getComputedStyle(root)\n      const available =\n        root.clientWidth -\n        (parseFloat(style.paddingLeft) || 0) -\n        (parseFloat(style.paddingRight) || 0)\n\n      // An element inside a closed tab, accordion or unopened dialog measures zero, which is\n      // not the same answer as \"nothing fits\". Keeping the previous string stops the text from\n      // collapsing to a lone ellipsis while it is off screen and staying that way.\n      if (!(available > 0)) return\n\n      const widthOf = (value: string) => {\n        probe.textContent = value\n        return probe.getBoundingClientRect().width\n      }\n      const fits = (value: string) => widthOf(value) - available <= WIDTH_TOLERANCE\n\n      let next: string | null = null\n      if (!fits(text)) {\n        // Largest number of surviving graphemes that still fits.\n        //\n        // Binary search is sound here even in a proportional font, where a wider string is not\n        // generally a longer one. Because the ratio is clamped to 0–1, both sides grow by at\n        // most one grapheme per step and neither ever shrinks, so each candidate is the\n        // previous one with a single grapheme inserted at the split — the search never swaps a\n        // wide glyph in for a narrow one, and width therefore rises with the count.\n        let low = 0\n        let high = graphemes.length - 1\n        let best = 0\n        while (low <= high) {\n          const mid = (low + high) >> 1\n          if (fits(buildCandidate(graphemes, mid, safeRatio, ellipsis))) {\n            best = mid\n            low = mid + 1\n          } else {\n            high = mid - 1\n          }\n        }\n        next = buildCandidate(graphemes, best, safeRatio, ellipsis)\n      }\n\n      // Leave nothing behind: a stale probe string would otherwise be picked up by ⌘F and by a\n      // select-all copy.\n      probe.textContent = \"\"\n      setTruncated(next)\n    }, [text, graphemes, safeRatio, ellipsis])\n\n    useIsomorphicLayoutEffect(measure, [measure])\n\n    // The container getting narrower is the whole reason this component exists.\n    React.useEffect(() => {\n      const root = rootRef.current\n      if (!root || typeof ResizeObserver === \"undefined\") return\n      const observer = new ResizeObserver(() => measure())\n      observer.observe(root)\n      return () => observer.disconnect()\n    }, [measure])\n\n    // A web font swapping in changes every glyph width without changing the box, so the resize\n    // observer never fires and the cut point would stay wherever the fallback font put it.\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    return (\n      <span\n        ref={rootRef}\n        // `overflow-hidden` earns its place three times: it clips the full string rendered\n        // before the first measurement, it contains the absolutely positioned probe (with\n        // `relative`) so it cannot widen an ancestor, and — because a box with a non-visible\n        // overflow has an automatic minimum size of zero — it is also what lets this shrink\n        // below its text inside a flex row or grid track, with no `min-w-0` needed.\n        className={cn(\"relative block overflow-hidden whitespace-nowrap\", className)}\n        {...props}\n      >\n        {truncated === null ? (\n          text\n        ) : (\n          <>\n            {/* Hidden from assistive tech: the shortened string is a visual convenience, and an\n                address read out as \"0x4f2a ellipsis 91bc\" is worse than useless. `title` lives\n                here rather than on the root so that hovering shows the full value without the\n                accessibility tree seeing it twice. `select-none` keeps the shortened copy out of\n                the clipboard, so selecting the line yields the full string below, once. */}\n            <span aria-hidden=\"true\" className=\"select-none\" title={text}>\n              {truncated}\n            </span>\n            <span className=\"sr-only\">{text}</span>\n          </>\n        )}\n        <span ref={probeRef} aria-hidden=\"true\" style={PROBE_STYLE} />\n      </span>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}