Smart Tooltip Positioning
Machine-coding brief for smart tooltips — flip/shift positioning, delay, hover/focus, and accessible naming.
- machine-coding
- interview
- react
- a11y
- positioning
Problem statement
Build a tooltip that positions itself around an anchor and flips/shifts when near viewport edges. Interviewers score measurement (getBoundingClientRect), portal rendering, hover/focus triggers, and not breaking accessibility (tooltips are not dialogs).
Requirements
Must have
- Show tooltip on hover and keyboard focus of trigger
- Hide on blur / pointer leave (with small delay to allow move into tooltip if interactive — usually tooltips are not interactive)
- Preferred placement:
top | bottom | left | right - Flip to opposite side if no room
- Portal to
document.bodyso overflow isn’t clipped - Accessible name: trigger gets
aria-describedbypointing at tooltip id
Should have
- Shift along axis to stay in viewport padding
- Show delay (300ms) / hide delay (100ms)
- Arrow pointer
Nice to have
- Collision with multiple edges (use Floating UI algorithm verbally)
- Touch long-press
Planning (5 minutes out loud)
- Non-interactive tooltip content only — if needs links, use popover/disclosure
- Measure trigger + tooltip + viewport
- Portal + fixed coordinates
- MVP — top placement + show/hide; then flip + shift
- Don’t use title attribute as the only solution
Architecture
Tooltip
├── Trigger (clone/wrapper)
└── Portal content (role=tooltip)
API
type Placement = "top" | "bottom" | "left" | "right";
type TooltipProps = {
content: React.ReactNode;
placement?: Placement;
children: React.ReactElement;
delayMs?: number;
};
Implementation sketch
Positioning math
type Coords = { top: number; left: number; placement: Placement };
function computePosition(
trigger: DOMRect,
tip: DOMRect,
preferred: Placement,
pad = 8
): Coords {
const vw = window.innerWidth;
const vh = window.innerHeight;
const candidates: Placement[] = [
preferred,
opposite(preferred),
...(["top", "bottom", "left", "right"] as Placement[]).filter(
(p) => p !== preferred && p !== opposite(preferred)
),
];
for (const placement of candidates) {
let top = 0;
let left = 0;
if (placement === "top") {
top = trigger.top - tip.height - pad;
left = trigger.left + (trigger.width - tip.width) / 2;
} else if (placement === "bottom") {
top = trigger.bottom + pad;
left = trigger.left + (trigger.width - tip.width) / 2;
} else if (placement === "left") {
top = trigger.top + (trigger.height - tip.height) / 2;
left = trigger.left - tip.width - pad;
} else {
top = trigger.top + (trigger.height - tip.height) / 2;
left = trigger.right + pad;
}
// shift into viewport
left = Math.min(Math.max(pad, left), vw - tip.width - pad);
top = Math.min(Math.max(pad, top), vh - tip.height - pad);
const fits =
placement === "top"
? trigger.top - tip.height - pad >= 0
: placement === "bottom"
? trigger.bottom + tip.height + pad <= vh
: placement === "left"
? trigger.left - tip.width - pad >= 0
: trigger.right + tip.width + pad <= vw;
if (fits) return { top, left, placement };
}
// fallback last candidate with shift only
return { top: pad, left: pad, placement: preferred };
}
function opposite(p: Placement): Placement {
return { top: "bottom", bottom: "top", left: "right", right: "left" }[p];
}
Component core
function Tooltip({
content,
placement = "top",
children,
delayMs = 300,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<Coords | null>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const tipRef = useRef<HTMLDivElement>(null);
const tipId = useId();
const showT = useRef<number>();
const hideT = useRef<number>();
function scheduleOpen() {
window.clearTimeout(hideT.current);
showT.current = window.setTimeout(() => setOpen(true), delayMs);
}
function scheduleClose() {
window.clearTimeout(showT.current);
hideT.current = window.setTimeout(() => setOpen(false), 100);
}
useLayoutEffect(() => {
if (!open || !triggerRef.current || !tipRef.current) return;
const next = computePosition(
triggerRef.current.getBoundingClientRect(),
tipRef.current.getBoundingClientRect(),
placement
);
setCoords(next);
}, [open, content, placement]);
const trigger = React.cloneElement(children, {
ref: (node: HTMLElement) => {
triggerRef.current = node;
},
"aria-describedby": open ? tipId : undefined,
onMouseEnter: scheduleOpen,
onMouseLeave: scheduleClose,
onFocus: scheduleOpen,
onBlur: scheduleClose,
});
return (
<>
{trigger}
{open &&
createPortal(
<div
ref={tipRef}
id={tipId}
role="tooltip"
style={{
position: "fixed",
top: coords?.top ?? -9999,
left: coords?.left ?? -9999,
}}
>
{content}
</div>,
document.body
)}
</>
);
}
First paint may use offscreen measure (-9999) then layout effect places it — acceptable for interviews; or render hidden for measure.
Accessibility essentials
role="tooltip"+aria-describedbyon trigger- Must work with keyboard focus, not hover only
- Don’t put focusable controls inside tooltips
- Escape can dismiss (optional APG)
- Respect delayed show so pointer transit doesn’t flicker
Performance notes
- Reposition on
scroll/resize(capture scroll on window) while open - Throttle with
requestAnimationFrame - Libraries (Floating UI) exist — OK to say you’d use them in prod
Footguns
- Overflow:hidden ancestors clipping without portal
- Hover-only tooltips excluding keyboard users
- Using title= with long delays and no styling control
- Interactive content in tooltip (use popover)
- Layout thrash measuring in tight loops without rAF
Interview out-loud answer
Tooltip content portals to body, is described by aria-describedby, and shows on focus and hover with delays. Positioning measures trigger and tooltip rects, tries preferred placement, flips, then shifts into the viewport. MVP is top+flip; arrow and scroll listeners next. For complex cases I’d use Floating UI rather than reimplementing middleware.
Related on this site
- Accessible Modal Dialog
- Custom Dropdown Select
- Avoiding Layout Thrashing
- Machine Coding Interview Framework