Text-Aware Tooltips in JavaScript: Fix Overflow and Flicker

tutorialMarch 29, 2026· 6 min read

Tooltip positioning seems like it should be trivial. Show a little text box near the trigger element. What could go wrong?

In practice, a lot. Tooltips are one of the most consistently broken UI components in web applications. They clip at the screen edge, appear in the wrong direction, or visibly jump after rendering. I've seen it in design systems from companies that definitely know better.

The root cause is almost always the same: we don't know how big the tooltip is until it's in the DOM, which means we have to render first, measure, then reposition — and users catch that flicker.

Why Tooltip Positioning Is Broken

The typical tooltip implementation looks something like this:

function showTooltip(trigger, text) {
  const tooltip = document.createElement('div')
  tooltip.textContent = text
  tooltip.className = 'tooltip'
  document.body.appendChild(tooltip)

  const triggerRect = trigger.getBoundingClientRect()
  const tooltipRect = tooltip.getBoundingClientRect() // needs to be in DOM first

  // Decide direction based on available space
  const spaceBelow = window.innerHeight - triggerRect.bottom
  if (spaceBelow < tooltipRect.height + 8) {
    tooltip.style.top = `${triggerRect.top - tooltipRect.height - 8}px`
  } else {
    tooltip.style.top = `${triggerRect.bottom + 8}px`
  }
  tooltip.style.left = `${triggerRect.left}px`
}

This has two problems:

Problem 1: The tooltip must be in the DOM to measure it. Calling getBoundingClientRect() before appending gives you all zeros. So you append to get dimensions, then reposition. Users see the tooltip jump.

Problem 2: Both getBoundingClientRect() calls force layout reflow. The first is on the trigger, the second on the tooltip. Two forced synchronous layout passes for every tooltip show.

Common attempted fixes:

  • Start with visibility: hidden — still in the DOM, still triggers layout, still may flicker briefly
  • Portal to document.body — avoids stacking context issues but doesn't solve the measurement-before-render problem
  • Fixed size tooltips — works only until someone pastes a long string into a tooltip

None of these address the root cause: we're measuring after rendering when we need to measure before.

How Pretext Solves This

Pretext measures text dimensions using canvas.measureText() — no DOM insertion required. Given the tooltip text, font, and maximum width, it returns the exact dimensions the tooltip will occupy when rendered.

import { prepare, layout } from '@chenglou/pretext'

async function getTooltipDimensions(text, maxWidth) {
  const FONT = '13px system-ui, sans-serif'
  const LINE_HEIGHT = 20
  const PADDING_V = 8
  const PADDING_H = 12

  const innerWidth = maxWidth - PADDING_H * 2
  const prepared = prepare(text, FONT)
  const { height: textHeight, lineCount } = layout(prepared, innerWidth, LINE_HEIGHT)

  // Single-line tooltips can be narrower than maxWidth
  const tooltipWidth = lineCount === 1
    ? Math.min(maxWidth, textHeight + PADDING_H * 2) // approximate for single-line
    : maxWidth

  return {
    width: tooltipWidth,
    height: textHeight + PADDING_V * 2,
  }
}

With the dimensions known before rendering, we can choose direction and clamp position before the tooltip ever appears in the DOM.

Building a Smart Tooltip

Here's the full tooltip positioning logic. The pattern is: measure → decide → position → render (once):

import { prepare, layout } from '@chenglou/pretext'

const TOOLTIP_FONT = '13px system-ui, sans-serif'
const TOOLTIP_MAX_WIDTH = 240
const TOOLTIP_LINE_HEIGHT = 20
const TOOLTIP_PADDING_V = 8
const TOOLTIP_PADDING_H = 12
const TOOLTIP_MARGIN = 8

async function computeTooltipPosition(triggerEl, tooltipText) {
  // Step 1: measure tooltip content — no DOM required
  const innerWidth = TOOLTIP_MAX_WIDTH - TOOLTIP_PADDING_H * 2
  const prepared = prepare(tooltipText, TOOLTIP_FONT)
  const { height: textHeight, lineCount } = layout(
    prepared, innerWidth, TOOLTIP_LINE_HEIGHT
  )
  const tooltipWidth = lineCount > 1 ? TOOLTIP_MAX_WIDTH : innerWidth + TOOLTIP_PADDING_H * 2
  const tooltipHeight = textHeight + TOOLTIP_PADDING_V * 2

  // Step 2: get trigger position (one getBoundingClientRect, on the trigger only)
  const triggerRect = triggerEl.getBoundingClientRect()

  // Step 3: choose direction based on available space
  const spaceBelow = window.innerHeight - triggerRect.bottom
  const spaceAbove = triggerRect.top

  const direction = spaceBelow >= tooltipHeight + TOOLTIP_MARGIN
    ? 'bottom'
    : spaceAbove >= tooltipHeight + TOOLTIP_MARGIN
    ? 'top'
    : 'bottom' // fallback — could also try left/right

  // Step 4: compute final position, clamped to viewport
  let top = direction === 'bottom'
    ? triggerRect.bottom + TOOLTIP_MARGIN
    : triggerRect.top - tooltipHeight - TOOLTIP_MARGIN

  let left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2
  left = Math.max(
    TOOLTIP_MARGIN,
    Math.min(left, window.innerWidth - tooltipWidth - TOOLTIP_MARGIN)
  )

  return { top, left, width: tooltipWidth, height: tooltipHeight, direction }
}

Now the tooltip can be rendered directly at its final position. No repositioning needed:

function Tooltip({ triggerRef, text, visible }) {
  const [pos, setPos] = useState(null)

  useEffect(() => {
    if (!visible || !triggerRef.current) return
    computeTooltipPosition(triggerRef.current, text).then(setPos)
  }, [visible, text])

  if (!visible || !pos) return null

  return (
    <div
      role="tooltip"
      style={{
        position: 'fixed',
        top: pos.top,
        left: pos.left,
        width: pos.width,
      }}
      className="tooltip"
    >
      {text}
    </div>
  )
}

The tooltip appears exactly once, at the correct position. No flicker, no jump.

Where Multiline Text Makes This Really Shine

Single-line tooltips are manageable with the old approach — if you hardcode a max-width and accept the occasional slight jump. But multiline tooltips are where the DOM-based approach breaks down badly.

Consider a tooltip with dynamic content: code snippets, API descriptions, error messages. The height depends on both the text and the container width:

// These two tooltips will have very different heights
const tooltip1 = 'Click to save'  // ~20px tall
const tooltip2 = `Warning: The current value exceeds the maximum allowed limit of 500 items. 
Remove some items before proceeding.`  // ~60px tall at 240px wide

With DOM-based measurement, you'd have to render both to know their heights. With pretext, you measure both before rendering, and your positioning logic handles them identically:

const dims1 = await getTooltipDimensions(tooltip1, 240) // { width: ~80, height: 36 }
const dims2 = await getTooltipDimensions(tooltip2, 240) // { width: 240, height: 72 }

Your positioning code doesn't care — it just uses dims.height to decide up/down and dims.width to centre horizontally.

Interactive Demo

Here's a working tooltip implementation powered by pretext. Each button has a tooltip with different text length. The positioning label inside each tooltip shows which direction was chosen and the computed dimensions:

⚡ Smart Tooltip Demo — hover each button

Watch "Bottom edge" — it detects the available space below and flips to appear above. The tooltip appears at its final position with no repositioning.

Handling Left/Right Directions

The example above only considers top/bottom. Real tooltips sometimes need to open left or right — for elements near the left or right viewport edge:

function pickDirection(triggerRect, tooltipWidth, tooltipHeight) {
  const margin = 8
  const vpW = window.innerWidth
  const vpH = window.innerHeight

  const spaceBelow = vpH - triggerRect.bottom
  const spaceAbove = triggerRect.top
  const spaceRight = vpW - triggerRect.right
  const spaceLeft = triggerRect.left

  if (spaceBelow >= tooltipHeight + margin) return 'bottom'
  if (spaceAbove >= tooltipHeight + margin) return 'top'
  if (spaceRight >= tooltipWidth + margin) return 'right'
  if (spaceLeft >= tooltipWidth + margin) return 'left'

  // No perfect fit — put it below and clip gracefully
  return 'bottom'
}

function positionForDirection(direction, triggerRect, tooltipWidth, tooltipHeight) {
  const margin = 8
  switch (direction) {
    case 'bottom': return { top: triggerRect.bottom + margin, left: triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2 }
    case 'top':    return { top: triggerRect.top - tooltipHeight - margin, left: triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2 }
    case 'right':  return { top: triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2, left: triggerRect.right + margin }
    case 'left':   return { top: triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2, left: triggerRect.left - tooltipWidth - margin }
  }
}

Because we know tooltipWidth and tooltipHeight before rendering, we can evaluate all four directions and pick the best one with a simple priority order.

Portal-Based Tooltips vs Pretext-Aware Tooltips

The other common approach to tooltip overflow bugs is portaling — appending the tooltip to document.body so it escapes any overflow: hidden ancestors:

// Portal approach
function TooltipPortal({ children }) {
  return ReactDOM.createPortal(children, document.body)
}

Portaling solves a different problem: tooltips being clipped by parent overflow settings. It doesn't solve the measurement problem. A portaled tooltip still needs to render before you can measure it, so the flip-on-render jump still happens.

The right approach is both: portal the tooltip (to escape stacking contexts) AND use pretext to measure before render (to eliminate the jump). They're complementary.

Takeaways

  • The classic tooltip flicker bug happens because we measure dimensions after rendering, then reposition
  • getBoundingClientRect() on a tooltip requires it to be in the DOM first — you can't know the size beforehand with DOM APIs
  • Pretext measures tooltip text dimensions via canvas before any DOM insertion — exact width and height, no layout reflow
  • Measure → decide direction → clamp to viewport → render once at final position: this is the correct order
  • Multiline tooltip text is where pretext really pays off: consistent positioning logic regardless of content length
  • Portaling and pretext measurement are complementary — portal to escape overflow: hidden, use pretext to avoid the repositioning jump