Why This Question Exists
Tooltips are a perfect interview question because they look trivial but surface real engineering challenges: overflow clipping, scroll-offset positioning, keyboard accessibility, and the Portal escape hatch. A candidate who just uses position: absolute fails the overflow test. A candidate who uses a Portal but forgets aria-describedby fails the accessibility audit.
The Wrong Approach: position: absolute
// ❌ WRONG — gets clipped by overflow:hidden parents
function Tooltip({ text, children }) {
const [show, setShow] = useState(false);
return (
<span style={{ position: 'relative' }}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}>
{children}
{show && <div style={{ position: 'absolute', top: '-30px' }}>{text}</div>}
</span>
);
}
This fails the moment any ancestor has overflow: hidden — the tooltip is clipped at the container boundary. In a real product, cards, modals, and sidebars all have overflow: hidden.
The Right Approach: React Portal
// ✅ CORRECT — Portal escapes all overflow containers
import { createPortal } from 'react-dom';
{coords && createPortal(
<div id={id} role="tooltip" style={{
position: 'fixed',
left: coords.x,
top: coords.y,
transform: TRANSFORMS[coords.position]
}}>
{text}
</div>,
document.body // mounted directly on body — no overflow parent
)}
createPortal renders the tooltip as a direct child of document.body in the real DOM, while keeping it in the React component tree. This means React events, refs, and context all still work, but the tooltip is visually outside every ancestor container.
Position Calculation with getBoundingClientRect
const show = () => {
const r = triggerRef.current.getBoundingClientRect();
// r gives viewport-relative coordinates — works with scroll!
const positions = {
top: { x: r.left + r.width / 2, y: r.top - OFFSET },
bottom: { x: r.left + r.width / 2, y: r.bottom + OFFSET },
left: { x: r.left - OFFSET, y: r.top + r.height / 2 },
right: { x: r.right + OFFSET, y: r.top + r.height / 2 },
};
setCoords({ ...positions[position], position });
};
Using getBoundingClientRect() is crucial. It returns coordinates relative to the viewport, not the document. Since we use position: fixed on the tooltip, these coordinates map directly to screen position — scrolling doesn't break anything.
Transform Offsets — the maths
const TRANSFORMS = {
top: 'translateX(-50%) translateY(-100%)',
// x is the centre of the trigger, so pull left by 50% of tooltip width
// y is the top of the trigger, so pull up by 100% of tooltip height
bottom: 'translateX(-50%)',
// x same, y is already at the bottom edge — no vertical shift needed
left: 'translateX(-100%) translateY(-50%)',
// x is the left edge of trigger, pull left by 100% of tooltip width
// y is the vertical centre, pull up by 50% of tooltip height
right: 'translateY(-50%)',
// x is the right edge — no horizontal shift
// y same as left
};
ℹ Interview TipSay: "I position the tooltip using position:fixed at viewport coordinates from getBoundingClientRect(). Then CSS transforms align it relative to the tooltip's own dimensions — because we don't know the tooltip's pixel size until it renders, transforms are the only reliable way to centre it."
Accessibility — the Full Picture
const id = useId(); // stable unique ID
<span
aria-describedby={coords ? id : undefined}
onFocus={show} // keyboard users
onBlur={hide} // keyboard users
>
{children}
</span>
<div id={id} role="tooltip">{text}</div>
aria-describedby connects the trigger to the tooltip via ID. When the trigger is focused, screen readers announce "button text, [tooltip text]". Without this, keyboard users and screen reader users get no tooltip content. role="tooltip" tells the assistive tech what kind of element this is.
⚠ Common Pitfall: Tooltip flickers on Portal hoverIf the tooltip overlaps the trigger (e.g., position bottom with a very short offset), the mouse enters the tooltip, triggering mouseLeave on the trigger, hiding the tooltip, which makes the mouse re-enter the trigger... causing a flicker loop. Fix: add pointer-events: none to the tooltip so it doesn't intercept mouse events.