Feedback

Overflow Tooltip

Text that reveals itself in a tooltip only when it is really truncated — width probe for a single line, height probe for a line clamp, and nothing at all when it fits.

Preview in your theme

Loading preview…

Registry dependencies
tooltip, utils
"use client"

import * as React from "react"

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"

// Sub-pixel layout rounds scrollWidth/scrollHeight up to whole pixels while clientWidth/Height is
// already integral, so a box that fits exactly can still report a 1px surplus. Real truncation is
// always bigger than that, and without the tolerance a share of every page's labels would grow a
// tooltip that reveals the exact same string.
const OVERFLOW_TOLERANCE_PX = 1

// Touch has no hover. Radix's trigger deliberately ignores touch pointers (its onPointerMove

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/overflow-tooltip.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "OverflowTooltip" component on top of the
shadcn/Radix Tooltip primitive (Tooltip, TooltipTrigger, TooltipContent,
TooltipProvider) plus the shared cn() class-merge helper. No other dependency,
no hand-rolled floating layer.

Contract
- Named export `OverflowTooltip`, forwardRef<HTMLSpanElement>, props extend the
  native span attributes minus `children` and `content` (`content` collides with
  React's RDFa attribute, so it has to be omitted before being redeclared):
  - `children: ReactNode` — rendered, measured, and reused as the tooltip body.
  - `lines?: number` — default 1. 1 truncates a single line with an ellipsis;
    2 or more clamps to that many lines. Clamp it with
    `Number.isFinite(lines) ? Math.max(1, Math.trunc(lines)) : 1` — 0, negative,
    fractional, NaN and Infinity all produce a box that shows nothing or a
    -webkit-line-clamp the browser silently drops, which takes the probe with it.
  - `content?: ReactNode` — tooltip body override, defaults to `children`. Use it
    when the visible content carries an icon or markup.
  - `side?: "top" | "right" | "bottom" | "left"` — default "top". Radix still
    flips away from collisions.
  - `delayDuration?: number` — default 300ms of hover intent. Overflow tooltips
    live in dense lists; firing instantly on every cell the pointer crosses is
    noise.
  - `onTruncatedChange?: (truncated: boolean) => void` — fired once the first
    measurement lands with the initial verdict, then on every flip. The
    unmeasured initial `false` of the first commit is never reported: keep the
    latest measured verdict and the last reported one in refs, and report only
    when the rendered verdict equals the measured one and differs from the last
    report. This is how a consumer (or a demo) can show that nothing was
    attached.
- The rendered element carries `data-truncated="true" | "false"` either way: a
  data attribute is inert for assistive tech but gives CSS and tests a hook.

Behavior
- One span does everything. It is the measured element AND the tooltip trigger —
  never wrap the text in an extra node, or the measurement stops describing the
  element the user actually sees.
- Detection, and why the two modes cannot share a probe:
  - lines === 1: `overflow-hidden text-ellipsis whitespace-nowrap` (Tailwind's
    `truncate`). The text can only escape sideways, so
    `scrollWidth - clientWidth > 1` is the verdict. A height probe would read
    "fits" forever, since a nowrap box never grows taller.
  - lines >= 2: `-webkit-line-clamp` via inline style (a template-literal
    `line-clamp-${n}` class is never seen by Tailwind's scanner) plus
    `overflow-wrap: anywhere`. The verdict is
    `scrollHeight - clientHeight > 1`. The width probe is worthless here:
    clamped text still soft-wraps at the box width, so scrollWidth stays exactly
    equal to clientWidth no matter how many lines were clamped away (measured in
    Chromium 151: eight lines of copy inside a three-line clamp still reports
    250 / 250). Chromium also computes `display: -webkit-box` as `flow-root`,
    which makes the box look ordinary and tempts you into the width probe.
  - `overflow-wrap: anywhere` on the clamp box is load-bearing, not cosmetic. A
    token too long to wrap escapes sideways instead of downwards, and the height
    probe is blind to that: the box still fits its N lines and reports "fits"
    while a 74-character token is visibly cut in half. Forcing the break keeps
    every kind of overflow vertical, which is the only kind the probe sees.
  - The 1px tolerance is not superstition: sub-pixel layout rounds scrollWidth up
    to whole pixels while clientWidth is already integral, so a box that fits
    exactly can report a 1px surplus and grow a tooltip that reveals the same
    string.
- Re-measure on all three triggers, because each one is blind to the others:
  - `ResizeObserver` on the element — the only thing that sees a resize that does
    not re-render React (a viewport change on a percentage-width container).
  - An effect with NO dependency array, so it runs after every commit — new
    children or a restyled parent change the glyphs without changing the box, so
    the observer never fires.
  - `document.fonts.ready` — a web font swap changes glyph widths, re-renders
    nothing, and does not resize the box either (a single line is sized by its
    container, a clamp box is line-height x N); without this the verdict stays
    frozen on fallback-font metrics.
  Disconnect the observer and guard the fonts promise with an `alive` flag on
  unmount.
- Keep the measured node in state, not a ref. Flipping the verdict swaps a plain
  span for a Radix-triggered one, which remounts the element; a ref would leave
  the observer watching a detached node forever. For the same reason, skip the
  measurement when `node.isConnected` is false: the effect that runs right after
  the swap still closes over the old, detached node, which reports 0 for every
  dimension — i.e. "fits" — and would ping-pong between the two branches.
- Nothing is attached while the text fits: no tooltip node, no `title`, no
  `aria-describedby`, no tab stop, not even an event listener. Screen readers are
  not the audience — CSS truncation is visual only, so assistive tech reads the
  full string either way; a tooltip on text that fits is pure noise for the
  people who never lost it.
- When it is truncated, the span becomes the Radix trigger with `tabIndex={0}`.
  Radix opens on hover and on focus, and attaches `aria-describedby` only while
  open, so the reference can never dangle. Two consequences worth naming: every
  truncated cell becomes a tab stop, and if the children already contain a
  focusable element you get two.
- Touch has no hover, and Radix deliberately ignores it (its trigger's
  onPointerMove returns early for pointerType "touch", and the focus that follows
  a tap is suppressed because a pointerdown just happened). Left alone, a touch
  user could never read the full text. Drive `open` as controlled state and add a
  500ms long press on touch pointers only: start a timer on pointerdown, clear it
  on pointerup / pointercancel / pointerleave and on unmount. The release fires a
  click and Radix's trigger closes on click, so the click that ended the long
  press must call preventDefault() — Radix's composed handler bails on
  defaultPrevented. A tap stays a tap; only a deliberate hold reveals. The
  tradeoff: on iOS a long press also competes with the native selection callout,
  and holding is not discoverable. Trading it for tap-to-toggle is a two-line
  change (see levers) — the thing you must not ship is a touch user with no path
  to the text at all.
- Mount the TooltipProvider inside the component so a cell that becomes truncated
  in an app without a provider cannot throw. Nested providers are legal; the cost
  is that instances do not share Radix's skip-delay grouping.

Rendering & styling
- Semantic tokens only. The trigger adds nothing but a layout-neutral focus ring
  (`rounded-sm focus-visible:ring-2 focus-visible:ring-ring`) — padding or a
  border here would change the measured box the moment the verdict flips, and the
  two branches could disagree forever.
- `block min-w-0` on the span: clientWidth/scrollWidth are meaningless on an
  inline box, and min-w-0 is what lets it shrink inside a flex row.
- The tooltip body gets `max-w-xs wrap-anywhere` so a long unbroken value wraps
  inside the bubble instead of stretching it, and `motion-reduce:animate-none` so
  reduced-motion visitors get the tooltip without the zoom/fade.
- cn() merges the consumer className onto the span; inline clamp styles are
  spread before the consumer's `style`, so the consumer still wins.

Customization levers
- Truncation shape: `lines` is the whole axis — 1 for a name column, 2-3 for a
  card description. The probe follows automatically.
- Reveal timing: `delayDuration` for hover intent; the long-press constant for
  touch. Setting the touch handler to open on pointerup instead of a timer turns
  it into tap-to-reveal.
- Tab-stop budget: dropping `tabIndex={0}` removes the per-cell tab stops in a
  huge table — only do it if the row already has a focusable control that
  exposes the full value some other way.
- Tooltip skin and placement: `side`, plus any className on TooltipContent
  (`max-w-md`, `text-sm`); swap the primitive for HoverCard if the body needs to
  be rich.
- Shared provider: delete the internal TooltipProvider and mount one at the app
  root to get Radix's skip-delay grouping across cells.
- Known hole to close if you hit it: a child with `white-space: nowrap` inside a
  clamp box escapes sideways where the height probe is blind. Either avoid
  nowrap children or OR the width probe back in for that case.

Concepts

  • Two truncation modes, two probes — a single line can only overflow sideways, a line clamp can only overflow downwards. Reusing the width probe on a clamp box is a silent false negative: clamped text still soft-wraps at the box width, so scrollWidth equals clientWidth however many lines were dropped.
  • Overflow-wrap as a measurement fix — overflow-wrap: anywhere on the clamp box is what keeps a giant unbreakable token from escaping sideways, where the height probe cannot see it. It is the probe's completeness, not a typographic preference.
  • Three re-measure triggers, none redundant — the ResizeObserver catches resizes that never re-render React; the post-commit measurement catches new children and restyled glyphs that never resize the box; document.fonts.ready catches the font swap, which does neither.
  • Attach nothing when it fits — no tooltip node, no title, no aria-describedby, no tab stop. CSS truncation never hid anything from a screen reader, so the tooltip is a sighted-user affordance and has no business in the accessibility tree when the text is fully visible.
  • Long press is the touch fallback — Radix's tooltip is pointer-and-focus driven and ignores touch by design. A 500ms hold on a truncated label opens it, and the click that ends the hold is suppressed so the tooltip survives the release.
  • Verdict flip remounts the element — going from plain span to Radix trigger swaps the DOM node, so the measured node lives in state and disconnected nodes are skipped; otherwise the effect right after the swap measures a detached element, reads zero, and ping-pongs forever.

On This Page