DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

The Tooltip Problem: A Little Box That Never Falls Off Screen

A tooltip sounds like the simplest UI component there is. Then you put a button in the top-right corner, hover it, and your tooltip gets clipped by the edge of the screen. Welcome to collision-aware positioning — the real problem that libraries like Floating UI exist to solve. Here's how to build it by hand.

Measure, don't guess

Everything starts with getBoundingClientRect(), which gives an element's position and size in viewport pixels. Read the trigger's rect (where it is) and the tooltip's rect (how big it is), plus innerWidth/innerHeight.

const r = trigger.getBoundingClientRect();
const t = tooltip.getBoundingClientRect();
const vw = innerWidth, vh = innerHeight;
Enter fullscreen mode Exit fullscreen mode

Measure the tooltip after it's visible — a hidden element reports zero size.

Flip

Pick a preferred side — say, top. Compute where the tooltip would go there. If its top edge would go above 0 (off-screen), flip it below the trigger:

let placement = "top";
let top = r.top - t.height - GAP;
if (top < 0) { placement = "bottom"; top = r.bottom + GAP; }
Enter fullscreen mode Exit fullscreen mode

This is the most noticeable smart behaviour — it's why a tooltip on a button at the very top of the page appears below it instead of being cut off.

Shift and clamp

Flipping fixes the main axis; shifting fixes the other one. Centre the tooltip on the trigger, then clamp it inside the viewport. The distance you moved is the "shift":

let left = r.left + r.width/2 - t.width/2;
const clamped = Math.max(GAP, Math.min(left, vw - t.width - GAP));
const shift = left - clamped;
left = clamped;
Enter fullscreen mode Exit fullscreen mode

The arrow that keeps pointing

Because the box moved by shift pixels, nudge the arrow back by the same amount so it still points at the trigger's centre. Without it, a shifted tooltip looks disconnected from what it describes.

Behaviour, not just position

A tooltip is only accessible if it works for everyone: open on mouseenter (after ~80ms so a passing pointer doesn't flash it) and on keyboard focus; close on mouseleave, blur, and Escape; give it role="tooltip" and link it with aria-describedby; and set pointer-events: none so it never steals the hover it depends on.

That's the whole engine — and the same one powers popovers, dropdowns and context menus. Try it (including buttons jammed in every corner) here:
https://dev48v.infy.uk/design/day22-tooltip.html

Top comments (0)