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 wideWith 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:
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