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…

"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 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 scrollWidth equals clientWidth however many lines were dropped.
  • Overflow-wrap as a measurement fixoverflow-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