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…
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 on mount with the
initial verdict and on every flip. 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.
Dock
A macOS-style dock — icon tiles magnify as the cursor approaches, with spring physics and pure-CSS label bubbles.