Positioning Tooltips Near a Text Selection

Show an extension tooltip next to the user's text selection: read the range rectangle, flip and clamp within the viewport, follow scrolling, handle iframes and RTL, and dismiss with the Popover API.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The user selects a word and your extension should show a small bubble right above it — a definition, a translation, a “save quote” button. The first attempt reads event.pageX from mouseup and positions a box there. It appears in the wrong place when the selection was made with the keyboard, off-screen when the word is at the top of the viewport, behind the page’s sticky header, and stuck in mid-air when the user scrolls. Selection tooltips are a positioning problem with a handful of well-known edge cases. This guide handles them. It belongs to in-page overlays and injected UI.

Where the selection actually is

The selection is a Range, and ranges know their own geometry: range.getBoundingClientRect() returns the box around the selected text in viewport coordinates, and range.getClientRects() returns one box per line for multi-line selections. That is the right anchor — not the mouse position, which is wrong for keyboard selection, double-click selection and touch handles. Viewport coordinates pair naturally with position: fixed, so a tooltip placed with the range rectangle needs no knowledge of scroll offsets, until the page scrolls — at which point both the range rectangle and the tooltip must be recomputed together.

From selection to a placed tooltipRead the selection's range, take its bounding rectangle, measure the tooltip, prefer placing it above and flip below if there is no room, clamp horizontally within the viewport, and reposition on scroll.getSelection()not collapsedrange rectviewport coordsmeasure tooltipafter showPopoverplace within the viewportprefer above8 px gapflip belowif no roomclamp x8 px margin
Measure, prefer, flip, clamp — then repeat on scroll and resize.

Step-by-step: a robust selection tooltip

1. Trigger on selection changes, not mouse events

 1// content script
 2let pending;
 3document.addEventListener("selectionchange", () => {
 4  clearTimeout(pending);
 5  pending = setTimeout(onSelectionSettled, 250);      // wait for the user to finish selecting
 6}, { signal });
 7
 8function onSelectionSettled() {
 9  const sel = getSelection();
10  if (!sel || sel.isCollapsed || sel.rangeCount === 0) return hideTip();
11  const text = sel.toString().trim();
12  if (text.length < 2 || text.length > 300) return hideTip();
13  if (sel.anchorNode?.parentElement?.closest("input, textarea, [contenteditable]")) return hideTip();
14  showTip(sel.getRangeAt(0), text);
15}

Execution context: the content script. selectionchange fires for mouse, keyboard and touch selection alike. The debounce avoids showing the tooltip on every intermediate state while the user drags. Skipping editable fields keeps the tooltip out of the way while the user writes. signal comes from the feature’s AbortController, so teardown removes the listener.

2. Show the tooltip as a popover in your shadow root

 1function ensureTip(root) {
 2  let tip = root.getElementById("tip");
 3  if (!tip) {
 4    tip = document.createElement("div");
 5    tip.id = "tip";
 6    tip.setAttribute("popover", "manual");            // we control dismissal precisely
 7    tip.setAttribute("role", "tooltip");
 8    root.append(tip);
 9  }
10  return tip;
11}

Execution context: the content script, inside your closed shadow root. A popover is promoted to the top layer when shown, so no page z-index can cover it. "manual" rather than "auto" because an auto popover closes on any click — including the click that adjusts the selection — which makes the tooltip flicker; step 6 handles dismissal explicitly.

3. Measure, prefer above, flip below, clamp sideways

 1const GAP = 8, MARGIN = 8;
 2
 3function place(tip, rangeRect) {
 4  const { width, height } = tip.getBoundingClientRect();
 5  const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
 6
 7  let top = rangeRect.top - height - GAP;               // prefer above
 8  let side = "above";
 9  if (top < MARGIN) { top = rangeRect.bottom + GAP; side = "below"; }   // flip
10  if (top + height > vh - MARGIN) top = Math.max(MARGIN, vh - height - MARGIN);
11
12  let left = rangeRect.left + rangeRect.width / 2 - width / 2;
13  left = Math.min(Math.max(MARGIN, left), vw - width - MARGIN);        // clamp
14
15  Object.assign(tip.style, { position: "fixed", top: `${top}px`, left: `${left}px`, margin: "0" });
16  tip.dataset.side = side;
17}

Execution context: the content script. The tooltip must be shown before it is measured, because a hidden popover has no size — call showPopover() with the tooltip transparent, measure, place, then make it visible. clientWidth excludes the scrollbar, unlike innerWidth, so the tooltip never sits under it. data-side lets CSS point an arrow up or down.

Placement rules for edge casesWhere the tooltip goes when the selection is near the top edge, the bottom edge, the left or right edge, or spans multiple lines.Selection positionVerticalHorizontalMiddle of viewportAboveCentredNear top edgeFlip belowCentredNear left/right edgeAboveClamped to 8 pxMulti-line selectionAbove first lineCentred on first lineTaller than viewportPinned to marginClamped
Prefer above; every other rule exists to keep the tooltip fully visible.

4. Anchor multi-line selections to the first line

1function anchorRect(range) {
2  const rects = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
3  return rects[0] ?? range.getBoundingClientRect();
4}

Execution context: the content script. The bounding box of a three-line selection spans the full paragraph width, so centring on it places the tooltip far from where the selection starts. Anchoring to the first line’s rectangle keeps the tooltip next to the beginning of the selected text, where the user’s eyes are. Zero-size rectangles appear at line breaks and are skipped.

5. Follow scrolling and layout changes

1let currentRange = null;
2function reposition() {
3  if (!currentRange || !tip.matches(":popover-open")) return;
4  const r = anchorRect(currentRange);
5  if (r.bottom < 0 || r.top > document.documentElement.clientHeight) return hideTip();   // scrolled away
6  place(tip, r);
7}
8addEventListener("scroll", () => requestAnimationFrame(reposition), { passive: true, capture: true, signal });
9addEventListener("resize", () => requestAnimationFrame(reposition), { passive: true, signal });

Execution context: the content script. Capturing scroll events catches scrolling inside nested containers — chat panes, code blocks — not just the window. requestAnimationFrame coalesces updates to one per frame. Hiding the tooltip when the anchor scrolls out of view is better than pinning it to the viewport edge, which detaches it from the text it explains.

Tooltip lifecycle across a scrollSelection settles and the tooltip is placed; the user scrolls, each frame recomputes the anchor rectangle and repositions; when the anchor leaves the viewport the tooltip hides; a click elsewhere also hides it.UserContent scriptTooltipselection settlesshowPopover + placescrollrAF: place(anchorRect)anchor off-screen → hidepointerdown outside
The range is the source of truth; the tooltip follows it every frame.

6. Dismiss on Escape, outside clicks and empty selections

 1document.addEventListener("keydown", (e) => { if (e.key === "Escape") hideTip(); }, { signal });
 2document.addEventListener("pointerdown", (e) => {
 3  if (e.composedPath().includes(host)) return;          // click inside our UI
 4  hideTip();
 5}, { signal, capture: true });
 6
 7function hideTip() {
 8  currentRange = null;
 9  if (tip.matches(":popover-open")) tip.hidePopover();
10}

Execution context: the content script. composedPath() includes the shadow host even for clicks inside a closed shadow root, which is how you tell “inside our tooltip” from “elsewhere”. Listening in the capture phase ensures the page cannot swallow the event first. The empty-selection case is already handled by step 1.

7. Handle right-to-left text and iframes

For right-to-left pages, prefer aligning the tooltip’s right edge to the selection’s right edge rather than centring when the selection is long, so the tooltip starts where the reader’s eye starts. Selections inside iframes belong to the iframe’s document: a content script injected with all_frames: true runs separately in each frame and positions its own tooltip in that frame’s viewport, which is usually what you want.

1const rtl = getComputedStyle(currentRange.startContainer.parentElement).direction === "rtl";

Execution context: the content script in whichever frame holds the selection. Cross-origin frames cannot read the parent’s geometry, so a tooltip that must float above the whole tab from inside an iframe is not possible; keep it inside the frame.

Common mistakes

  • Using mouse coordinates. Wrong for keyboard and touch selection; use the range’s rectangles.
  • Measuring a hidden element. A hidden popover reports zero size. Show, measure, place.
  • Auto popovers for selection tooltips. They close on the click that adjusts the selection. Use manual popovers with your own dismissal.
  • Ignoring nested scroll containers. Window scroll events miss them; listen in the capture phase.
  • Showing a tooltip for every selection. Filter by length and context, or offer it only after a modifier key or a hover on a small trigger, to avoid getting in the way of ordinary text selection.

Cross-browser variation

  • Chrome / Edge: popovers from 114; getClientRects on ranges is reliable across inline elements.
  • Firefox: popovers from 125. Selections across shadow DOM boundaries are reported differently; restrict the feature to light-DOM text for consistency.
  • Safari: popovers from 17. On iOS, the native selection callout appears above the selection; place your tooltip below on touch devices to avoid overlapping it.

Verification

  1. Select a word with the mouse, with Shift+Arrow keys, and by double-clicking: the tooltip appears above it each time.
  2. Select a word on the first visible line: the tooltip flips below.
  3. Select near the right edge: the tooltip stays fully visible.
  4. Scroll slowly: the tooltip follows the word, then hides when the word leaves the viewport.
  5. Press Escape and click elsewhere: the tooltip hides; click inside the tooltip: it stays.

FAQ

Should I use a positioning library?

Floating UI and similar libraries handle flipping, shifting and arrows well and can be bundled into the content script. The logic above is small enough for one tooltip; reach for a library when you have several anchored elements.

Can the tooltip read the selection inside a PDF?

Not in Chrome’s built-in PDF viewer, which is an extension page content scripts cannot access. Firefox’s PDF.js viewer is a normal page and can be targeted.

How do I keep the tooltip from covering the selection on small screens?

On narrow viewports, place it below and full-width, or dock it to the bottom of the viewport like a sheet.

Other UI/UX Patterns & Interactive Components Resources