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.
Loading preview…
- Registry dependencies
- tooltip, utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/overflow-tooltip.jsonPrompt
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
scrollWidthequalsclientWidthhowever many lines were dropped. - Overflow-wrap as a measurement fix —
overflow-wrap: anywhereon 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.readycatches the font swap, which does neither. - Attach nothing when it fits — no tooltip node, no
title, noaria-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.
Dismissible
A wrapper that adds a close button to any content and remembers it was closed — for this tab, forever, or until the copy is versioned up — with an undo window before anything is written.
Retry Countdown
A backoff card that derives every wait from the schedule you pass — attempt against the cap, a ring draining to the next automatic attempt, a retry-now that skips the wait, the reason the last attempt failed, and a terminal state at the cap that offers your escape hatch instead of a dead button.