{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "overflow-tooltip",
  "title": "Overflow Tooltip",
  "description": "Text that reveals itself in a tooltip only when it is actually cut off — measured, not guessed. Reach for it wherever a string has to fit a box somebody else sized: a table cell, a file, branch or project name in a sidebar, a tab label, a breadcrumb, a card or dialog title, an email subject line, a chip in a row of chips, a legend entry on a chart, a path in a file tree, a commit message in a list. Common asks it answers: \"show tooltip only when text is truncated react\", \"detect if text is ellipsized\", \"is text truncated javascript\", \"check if element has ellipsis\", \"scrollWidth vs offsetWidth truncation\", \"useIsTruncated hook\", \"conditional tooltip on overflow\", \"title attribute only when needed\", \"tooltip for overflowing table cell\", \"ellipsis tooltip shadcn\", \"text-overflow ellipsis show full text on hover\", \"react truncated text tooltip\", \"line-clamp detect truncation\", \"webkit-line-clamp is text clamped\", \"show full text when clamped\", \"antd Typography ellipsis tooltip equivalent\", \"MUI Tooltip only if overflow\", \"don't show tooltip when text fits\", \"tooltip flickers on every cell\", \"title attribute read twice by screen reader\", \"accessible tooltip for truncated text\", \"keyboard access to truncated text\", \"truncated text not focusable\", \"ResizeObserver text truncation\", \"re-measure truncation on resize\", \"truncation wrong after font loads\", \"document.fonts.ready measure text\", \"省略されたときだけツールチップ\", \"文字が切れているか判定\", \"三点リーダー 全文表示\", \"テーブルセル 省略 ツールチップ\", \"title属性 読み上げ 二重\", \"切り詰め 判定 react\". Official shadcn/ui has the tooltip and nothing that decides when to use one. Measured across its whole registry today — 65 items listed, 62 fetchable, code-block, questionnaire and trace indexed and 404 on both style tracks, 223,287 bytes of files[].content concatenated — scrollWidth, offsetWidth and isTruncated are every one of them a zero hit, and the only two matches for ellipsis are BreadcrumbEllipsis and PaginationEllipsis, which are static three-dot glyphs you place yourself rather than a measurement of anything. tooltip is a Radix wrapper: it shows the tooltip you asked for, and the asking is the part that gets hand-rolled. So the reflex is title={name} on every cell, and the reflex is the expensive mistake. The negative rule is the whole component: showing a tooltip over text that was never clipped is worse than showing nothing. A tooltip on every cell is a table that flickers boxes at you as the pointer crosses it; a tab stop on every cell is a table a keyboard cannot get past; and because CSS clipping is visual only — the text node is complete whatever the paint does with it — title is a second copy of a string the screen reader already has, which it then reads twice. title is also slow, cannot be styled or dismissed, and never appears on touch at all. So here the tooltip, the tab stop and the handlers exist exactly while the text is clipped, and it is an ordinary span the rest of the time; the tooltip itself is aria-hidden, because nothing was ever hidden from assistive technology and the remedy is for eyes. The tab stop is for the sighted keyboard user who cannot hover, and it appears and disappears with the clipping. Three measurements decide it, and two of them are the ones hand-rolled versions miss. A box is clipped when scrollWidth exceeds clientWidth by more than a pixel of slack: sweeping a box through its text's natural width in tenths of a pixel, every box at least as wide as its text reported exactly 0, so the rounding cancels and there are no false positives from it — but a box 0.2px too narrow already reports 1, and a fifth of a pixel off the last glyph is not worth interrupting a table for. Fonts are the second: a box sized for the fallback face reports that its text fits and the same box is 8 pixels over once the real face swaps in, with no resize and no re-render to notice it, so a component that measures on mount is wrong from then on and wrong in the direction that calls a clipped cell fine — document.fonts.ready settles that once per page. Resizing is the third: dragging a sidebar or a column narrower flips a cell from fitting to clipped and the only notification is a ResizeObserver on the cell. Multi-line is a different axis, not a variation. A paragraph cut off by -webkit-line-clamp overflows downwards and not at all sideways — measured at a two-line clamp, 0 pixels of horizontal overflow and 51 of vertical — so a component that only ever compares widths says \"not truncated\" about every clamped paragraph it is given, silently. Pass lines={2} and the comparison moves to the height, and the clamp is set as a style rather than a line-clamp-N class, because that class only exists for the few N a build has already seen. The API: children (the text), label for the full string when the children are mixed content such as a highlighted match — an empty or blank label means there is nothing to show rather than an empty box, lines for a clamped paragraph, threshold for the slack, side to flip the tooltip below for the top row, delay for how long the pointer must rest (focus opens it at once, since arriving by keyboard is already a commitment), disabled for a cell whose full text is shown elsewhere, and onOverflowChange, which is told once per change rather than once per measurement, so a parent that re-renders on it cannot loop. isOverflowing is the comparison as a plain function of four numbers, and useIsOverflowing is the measurement with its observers, exported for a \"show full\" button or an expanding row that needs the same answer without this markup. Escape closes the tooltip and stops there, so it does not also close the dialog underneath. The tooltip is a sibling of the measured text rather than a child — verified against a browser, adding it leaves scrollWidth and clientWidth untouched — and it is positioned inside the wrapper rather than portalled, so an ancestor with overflow:hidden will clip it: put the scroll container on a parent it is allowed to escape, or use side=\"bottom\" in the top row. In a flex row, give the parent min-w-0 or nothing truncates at all. Styled with shadcn tokens so it follows light and dark themes, and it ships with no dependencies at all — no Radix, no popper, no tooltip library. Distinct from its neighbours here: middle-truncate always shortens one string and does it in the middle so the extension survives, where this leaves the clipping to CSS and only decides whether to explain it; read-more folds a long body behind a control instead of revealing it on hover; scroll-shadow says there is more content past a scrolling edge, which is the same measure-then-observe discipline applied to a box rather than to a line; toolbar-overflow decides which whole controls survive a width; and highlight-text marks a match inside a string that may itself be the one being clipped here.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ui/overflow-tooltip.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Pixels of hidden text that are too few to be worth a tooltip.\n *\n * Both numbers in the comparison are rounded integers, and measuring a box through its text's\n * natural width in tenths of a pixel shows that they round together: across a sweep from one pixel\n * under to one pixel over, **every box at least as wide as its text reported an overflow of\n * exactly 0**. So the usual sub-pixel worry does not apply to this axis, and a `> 0` test raises no\n * false positives of its own.\n *\n * What the sweep does show is the other end: a box 0.2px too narrow reports an overflow of 1,\n * because the rounding has to go somewhere. A fifth of a pixel off the end of the last glyph is not\n * something anybody can see, and interrupting a table with a tooltip over it is worse than letting\n * it clip. One pixel of slack is where those two meet.\n */\nconst DEFAULT_THRESHOLD = 1\n\n/** What the element reports about itself, and which axis the clipping happens on. */\nexport interface OverflowMetrics {\n  scrollWidth: number\n  clientWidth: number\n  scrollHeight: number\n  clientHeight: number\n}\n\n/**\n * Whether the text is actually clipped.\n *\n * `lines` picks the axis, and it has to, because the two kinds of clipping are invisible to each\n * other's measurement. A single line ellipsised with `text-overflow` overflows horizontally and\n * not at all vertically; a paragraph cut off by `-webkit-line-clamp` is the exact reverse —\n * measured, a two-line clamp reported **0 pixels of horizontal overflow and 51 of vertical**. A\n * component that only ever compares widths therefore says \"not truncated\" about every clamped\n * paragraph it is ever given, and does it silently.\n */\nexport function isOverflowing(\n  metrics: OverflowMetrics,\n  { lines = 1, threshold = DEFAULT_THRESHOLD }: { lines?: number; threshold?: number } = {}\n): boolean {\n  return lines > 1\n    ? metrics.scrollHeight - metrics.clientHeight > threshold\n    : metrics.scrollWidth - metrics.clientWidth > threshold\n}\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nexport interface UseIsOverflowingOptions {\n  /** 1 for a single ellipsised line (the default), more for a `-webkit-line-clamp` paragraph. */\n  lines?: number\n  /** Pixels of hidden text that still count as fitting. Defaults to 1. */\n  threshold?: number\n  /** Re-measure when this changes, for content that is not a plain string. */\n  deps?: React.DependencyList\n}\n\n/**\n * Whether `ref`'s text is clipped right now, kept correct as the box and the fonts change.\n *\n * Exported because the answer is useful without a tooltip attached to it — a \"show full\" button, a\n * row that expands, an icon in the corner of a cell.\n *\n * Two of the three re-measurements are the reason this is not one line in a render:\n *\n * **Fonts arrive after the first measurement and change the answer.** A box sized for the fallback\n * face reports that its text fits; when the real face swaps in, the same box is 8 pixels over\n * (measured, at a boundary chosen to be near the edge). Nothing fires a resize on the cell and\n * nothing re-renders the tree, so a component that measured once on mount is wrong from then on,\n * and wrong in the direction where it says a clipped cell is fine. `document.fonts.ready` settles\n * once per page and is cheap to wait for.\n *\n * **A resize changes the answer with no event attached.** Dragging a sidebar or a column narrower\n * flips a cell from fitting to clipped, measured, and the only notification of it is a\n * ResizeObserver on the cell.\n *\n * The third is the ordinary one: the text itself changing, which React already tells us about, so\n * the measurement runs on every render. Reading `scrollWidth` forces layout but changes nothing,\n * and nothing this component does to the element afterwards — a tab stop, a tooltip positioned out\n * of flow — alters its size, so there is no loop to damp.\n */\nexport function useIsOverflowing(\n  ref: React.RefObject<HTMLElement | null>,\n  { lines = 1, threshold = DEFAULT_THRESHOLD, deps = [] }: UseIsOverflowingOptions = {}\n): boolean {\n  const [overflowing, setOverflowing] = React.useState(false)\n\n  const measure = React.useCallback(() => {\n    const el = ref.current\n    if (!el) return\n    setOverflowing(isOverflowing(el, { lines, threshold }))\n  }, [ref, lines, threshold])\n\n  // Every render, because the text is the thing most likely to have changed and it is the one\n  // change no observer reports as a size change of this element.\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  useIsomorphicLayoutEffect(measure, [measure, ...deps])\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n\n    const observer = new ResizeObserver(measure)\n    observer.observe(el)\n\n    // A face that has already loaded resolves this immediately, so the common case costs one\n    // microtask. `cancelled` rather than an AbortController because the only thing to undo is the\n    // callback, and a component unmounted between the two would otherwise set state after the fact.\n    let cancelled = false\n    const fonts = typeof document !== \"undefined\" ? document.fonts : undefined\n    fonts?.ready.then(() => {\n      if (!cancelled) measure()\n    })\n\n    return () => {\n      cancelled = true\n      observer.disconnect()\n    }\n  }, [ref, measure])\n\n  return overflowing\n}\n\nexport interface OverflowTooltipProps\n  extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  /** The text. Clipped by CSS, shown in full in the tooltip. */\n  children: React.ReactNode\n  /**\n   * The full text, when `children` is not a plain string.\n   *\n   * Only needed for mixed content — a highlighted match, a badge next to a name. With a string\n   * child this is the string.\n   */\n  label?: string\n  /** 1 for a single ellipsised line (the default), more for a clamped paragraph. */\n  lines?: number\n  /** Pixels of hidden text that still count as fitting. Defaults to 1. */\n  threshold?: number\n  /** Which side of the text the tooltip sits on. Defaults to \"top\". */\n  side?: \"top\" | \"bottom\"\n  /** How long the pointer has to rest before the tooltip opens, in ms. Focus opens it at once. */\n  delay?: number\n  /** Never open, and never take a tab stop. For a cell whose full text is already shown elsewhere. */\n  disabled?: boolean\n  /** Told when the text starts or stops being clipped. */\n  onOverflowChange?: (overflowing: boolean) => void\n  tooltipClassName?: string\n}\n\n/**\n * Text that shows its full self in a tooltip **only when it is actually cut off**.\n *\n * For the places where a string has to fit a box somebody else sized: a table cell, a file or\n * project name in a sidebar, a tab, a breadcrumb, a card title, an email subject, a chip in a row\n * of chips. Give it the width to fit in — from the table layout, a `max-w-*`, a flex parent — and\n * it clips with an ellipsis and reveals the rest on hover and on focus.\n *\n * The rule it exists to enforce is the negative one. **Showing a tooltip over text that was never\n * clipped is the more expensive mistake**, and it is what every `title={name}` on every cell does:\n * a tooltip on everything is a page that flickers boxes at you as the pointer crosses it, and a\n * tab stop on everything is a table a keyboard cannot get past. So the tooltip, the tab stop and\n * the hover handlers all exist exactly when the text is clipped, and the component is an ordinary\n * `<span>` the rest of the time.\n *\n * It does not use the `title` attribute, which is the other thing people reach for. `title` is\n * slow, cannot be styled or dismissed, does not appear on touch at all — and, because CSS clipping\n * is visual only, it is a **second copy of text a screen reader already has**, which it then reads\n * twice. That is also why the tooltip here is `aria-hidden`: nothing is hidden from assistive\n * technology in the first place, since the text node is complete whatever the paint does with it.\n * What is hidden is hidden from eyes, so the remedy is for eyes, and the tab stop is there for the\n * sighted keyboard user who cannot hover.\n *\n * The tooltip is positioned inside the wrapper rather than portalled, so it is clipped by an\n * ancestor with `overflow: hidden` — which a scrolling table has. Put the scroll container on a\n * parent that the tooltip is allowed to escape, or use `side=\"bottom\"` in the top row.\n */\nexport const OverflowTooltip = React.forwardRef<HTMLSpanElement, OverflowTooltipProps>(\n  function OverflowTooltip(\n    {\n      children,\n      label,\n      lines = 1,\n      threshold = DEFAULT_THRESHOLD,\n      side = \"top\",\n      delay = 300,\n      disabled = false,\n      onOverflowChange,\n      className,\n      tooltipClassName,\n      // Pulled out rather than left in `props`, because these five are load-bearing here: spread\n      // after the component's own, a caller's handler replaces the one that opens the tooltip and\n      // the component quietly stops working; spread before, the caller's never runs. Both are\n      // called.\n      onPointerEnter,\n      onPointerLeave,\n      onFocus,\n      onBlur,\n      onKeyDown,\n      ...props\n    },\n    ref\n  ) {\n    const textRef = React.useRef<HTMLSpanElement>(null)\n    const overflowing = useIsOverflowing(textRef, { lines, threshold, deps: [children] })\n    const active = overflowing && !disabled\n    const [open, setOpen] = React.useState(false)\n    const timer = React.useRef<number | null>(null)\n    const id = React.useId()\n\n    const onOverflowChangeRef = React.useRef(onOverflowChange)\n    onOverflowChangeRef.current = onOverflowChange\n    // Guarded on the last value reported rather than on the dependency array alone, so the caller\n    // is told once per change even when the effect itself runs twice — which it does under strict\n    // mode in development, and which is how a parent that re-renders on this callback turns a\n    // measurement into a loop.\n    const reported = React.useRef<boolean | null>(null)\n    React.useEffect(() => {\n      if (reported.current === overflowing) return\n      reported.current = overflowing\n      onOverflowChangeRef.current?.(overflowing)\n    }, [overflowing])\n\n    const cancel = React.useCallback(() => {\n      if (timer.current !== null) {\n        window.clearTimeout(timer.current)\n        timer.current = null\n      }\n    }, [])\n\n    // A tooltip left open by text that stopped being clipped — the column was widened while the\n    // pointer rested on it — has nothing left to say.\n    React.useEffect(() => {\n      if (!active && open) setOpen(false)\n    }, [active, open])\n\n    React.useEffect(() => cancel, [cancel])\n\n    const openLater = () => {\n      if (!active) return\n      cancel()\n      timer.current = window.setTimeout(() => setOpen(true), delay)\n    }\n    const close = () => {\n      cancel()\n      setOpen(false)\n    }\n\n    // An empty or blank label is \"nothing to show\" rather than an empty box: a caller computing\n    // the full text from data has one for every row where the data is missing.\n    const candidate = label ?? (typeof children === \"string\" ? children : undefined)\n    const full = candidate && candidate.trim() !== \"\" ? candidate : undefined\n\n    return (\n      <span\n        ref={ref}\n        className={cn(\"relative inline-block max-w-full align-bottom\", className)}\n        onPointerEnter={(event) => {\n          openLater()\n          onPointerEnter?.(event)\n        }}\n        onPointerLeave={(event) => {\n          close()\n          onPointerLeave?.(event)\n        }}\n        // Focus opens immediately: a keyboard user has already committed to this element by\n        // arriving at it, and a delay there reads as the component being broken.\n        onFocus={(event) => {\n          if (active) setOpen(true)\n          onFocus?.(event)\n        }}\n        onBlur={(event) => {\n          close()\n          onBlur?.(event)\n        }}\n        onKeyDown={(event) => {\n          if (event.key === \"Escape\" && open) {\n            // Stopped here so Escape does not also close the dialog or the menu this sits in —\n            // dismissing the thing on top is the whole of what the key was pressed for.\n            event.stopPropagation()\n            close()\n          }\n          onKeyDown?.(event)\n        }}\n        {...props}\n      >\n        <span\n          ref={textRef}\n          // The tab stop exists exactly while it is useful. A permanent one would put every cell\n          // of a table into the tab order to buy the handful that are clipped.\n          tabIndex={active ? 0 : undefined}\n          className={cn(\n            \"block rounded-sm\",\n            lines > 1 ? \"overflow-hidden\" : \"truncate\",\n            active &&\n              \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background\"\n          )}\n          style={\n            lines > 1\n              ? // No Tailwind class is emitted for an arbitrary clamp count, and `line-clamp-N`\n                // only exists for the few N that are generated — a prop that takes a number has to\n                // set it here or quietly do nothing for N the build never saw.\n                {\n                  display: \"-webkit-box\",\n                  WebkitBoxOrient: \"vertical\",\n                  WebkitLineClamp: lines,\n                }\n              : undefined\n          }\n        >\n          {children}\n        </span>\n\n        {active && open && full !== undefined ? (\n          <span\n            id={id}\n            role=\"tooltip\"\n            // The text is already complete in the accessibility tree — CSS clipped the paint, not\n            // the DOM — so announcing this too would be the same sentence twice. See the note on\n            // the component.\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute left-0 z-50 w-max max-w-xs rounded-md border border-border\",\n              \"bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md\",\n              side === \"top\" ? \"bottom-full mb-1\" : \"top-full mt-1\",\n              tooltipClassName\n            )}\n          >\n            {full}\n          </span>\n        ) : null}\n      </span>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
