How Floating UI Measures, Positions, and Keeps Popovers Alive
From computePosition to autoUpdate: A deep dive into Floating UI floating component implementation
Tooltips, Select dropdowns, Mention suggestions, Popovers… these components that "float next to an element" look like just position: absolute plus a few offsets. Anyone who has actually written them knows: parent overflow will clip, scrolling must follow, viewport edges require flipping, and Mention needs to stick to the cursor rather than the input box.
Floating UI splits this into two layers:
- Positioning: Where the floating layer attaches, how it avoids edges.
- Interactions: When to open, when to close; how focus, keyboard, and screen readers behave while open.
This article focuses only on Positioning. It will follow computePosition to explain how coordinates are calculated and how overflow is detected, then use it to implement a Select and a Mention, and finally dissect the autoUpdate source code: how scrolling, resize, layout shift, and per-frame comparison each trigger recalculation.
1. First, understand three measurement APIs
The essence of positioning is "measuring the relative position of two boxes." Browsers commonly use three APIs, with completely different responsibilities.
| Feature | getBoundingClientRect() |
IntersectionObserver |
MutationObserver |
|---|---|---|---|
| Execution mechanism | Synchronous, returns on call | Asynchronous, callback notification | Asynchronous, callback after DOM changes |
| Performance | Forces layout, frequent calls cause jank | Main-thread friendly | Only cares about structural changes |
| Typical use | Precise coordinates at a moment, drag calculations | Lazy loading, exposure tracking, viewport intersection | Watching node and subtree additions/removals |
Floating UI's positioning algorithm itself uses getBoundingClientRect: it needs the precise rectangles of the reference and floating elements in a given frame. autoUpdate does the opposite — it tries to avoid polling getBoundingClientRect, so the layout shift path uses IntersectionObserver for a "frame-box" detection trick. This technique will be covered in detail later.
2. computePosition: the complete pipeline for a single positioning
Minimal usage:
computePosition(button, tooltip, {
placement: 'top',
middleware: [
offset(6),
flip(),
shift({ padding: 5 }),
arrow({ element: arrowElement }),
],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`,
});
const { x: arrowX, y: arrowY } = middlewareData.arrow;
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]];
Object.assign(arrowElement.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px',
});
});
The returned x / y are the coordinates of the floating element's top-left corner relative to the positioning origin. The DOM platform uses getBoundingClientRect to get rectangles; other platforms (canvas, React Native) swap in a different measurement implementation while the algorithm itself stays the same. @floating-ui/dom's computePosition essentially injects the DOM platform into the core.
The four main directions correspond to four sets of top-left formulas. Taking top as an example: the floating element's bottom edge touches the reference's top edge, and the horizontal direction uses start / end for additional alignment offset.
Middleware runs in array order, and each step can change x / y, change placement, and stuff data into middlewareData:
offset(6): Pushes 6px along the main axis, giving the arrow room.flip(): If the current direction overflows, switch to the opposite side.shift({ padding: 5 }): If it still overflows after flipping, translate along the cross axis to guarantee at least 5px of padding.arrow({ element }): Calculates the arrow'sx / yrelative to the tooltip itself, not relative to the reference. ThestaticSidelogic above pins the arrow to the edge of the tooltip facing the reference.
detectOverflow: clip and overflow in four directions
The judgment basis for flip / shift both come from detectOverflow: take the floating element's current rectangle and compare it against the clip, giving an overflow amount for each of the four directions. A positive number means it has crossed the boundary.
The so-called clip is not simply the viewport, but the "intersection of all clipping boundaries": walk up from the floating element, collect all ancestors with overflow: hidden / auto / scroll / clip, plus the viewport, and take the minimum intersection of their visible areas. If any side of the floating layer exceeds this intersection, it counts as overflow.
flip sees main-axis overflow and flips sides; shift sees cross-axis overflow and translates. The two are often used together: flip first, and if it still sticks to the edge after flipping, push further.
3. Implementing a Select with computePosition
Positioning only solves "where to attach." A usable Select also needs to handle controlled values, open/close state, and closing when clicking outside.
Core props: value, open, options, onChange, className.
Design points:
- Whether options render is controlled by
open. Unmount when closed to avoid hidden nodes continuing to participate in layout and focus. - The dropdown panel attaches to the trigger's ref via
computePosition.placementtypically uses'bottom-start', withoffset/flip/shiftattached. valuesupports both controlled and uncontrolled modes. Ifvalueis passed externally, follow the external source; if not, use internaluseState, the same pattern as<input>'s defaultValue.- Clicking outside options closes the dropdown. Listen to
document'smousedown; if the click lands outside the root node, callsetOpen(false).
useEffect(() => {
if (!open) {
return;
}
const onDocMouseDown = (event: MouseEvent) => {
const target = event.target as Node;
if (rootRef.current?.contains(target)) {
return;
}
setOpen(false);
};
document.addEventListener('mousedown', onDocMouseDown);
return () => document.removeEventListener('mousedown', onDocMouseDown);
}, [open]);
Using mousedown instead of click ensures the panel closes before blur / focus switching happens. When open is false, the listener is immediately removed to avoid a persistent global listener.
The positioning code itself is thin: after opening, run computePosition once on the trigger and panel, and write the returned x / y to the panel's left / top. Whether to continuously recalculate during scrolling and window resizing is the job of autoUpdate in the next section.
4. Mention: when the reference is no longer a DOM node
Select's reference is a button, with a ready-made rectangle. Mention needs to attach to the cursor position inside an input box, and there is no DOM node for that.
The trigger logic first uses string APIs:
slice(0, caret)extracts the text before the cursor.lastIndexOf('@')finds the last@before the cursor.- If there is no space between
@and the cursor, it is considered an active mention input, and candidates pop up.
What's missing is "the screen coordinates of this @ / cursor." The approach is to create a hidden span with the same font as the input, fill it with the characters before the cursor, and use span.offsetWidth as the horizontal offset:
function getCaretRect(input: HTMLInputElement) {
const style = getComputedStyle(input);
const span = document.createElement('span');
span.textContent = input.value.slice(0, input.selectionStart ?? 0);
span.style.font = style.font;
span.style.letterSpacing = style.letterSpacing;
span.style.whiteSpace = 'pre';
span.style.position = 'fixed';
span.style.top = '-9999px';
document.body.append(span);
const box = input.getBoundingClientRect();
const x =
box.left +
parseFloat(style.paddingLeft) +
parseFloat(style.borderLeftWidth) +
span.offsetWidth -
input.scrollLeft;
const y =
box.top + parseFloat(style.paddingTop) + parseFloat(style.borderTopWidth);
const height = input.clientHeight;
span.remove();
return {
x,
y,
width: 0,
height,
top: y,
left: x,
right: x,
bottom: y + height,
};
}
A few points worth calling out:
whiteSpace: 'pre'preserves spaces; otherwise the measured width would place the cursor further left than it actually is.- Subtracting
scrollLeftkeeps the cursor coordinate correct after the input content exceeds the width and scrolls. - The return value is isomorphic to
DOMRect:x / y / width / height / top / left / right / bottom. Floating UI's reference does not have to be an Element — passing a virtual element works:
const virtualEl = {
getBoundingClientRect: () => getCaretRect(input),
};
computePosition(virtualEl, mentionList, {
placement: 'bottom-start',
middleware: [offset(4), flip(), shift({ padding: 8 })],
});
computePosition only recognizes "something that can provide a rectangle." Buttons, cursors, selections, mouse pointers — all can serve as references. The hard part of Mention is not the middleware, but measuring the caret into a valid rectangle.
For a single-line <input>, mirroring with a span is sufficient. Multi-line <textarea> also needs to handle line-break height; a common approach is to add another mirror container, or for contenteditable use getClientRects() to get the native caret rectangle. The idea is the same: first get a rectangle, then hand it to computePosition.
5. Why Select attaches to body, and why autoUpdate exists
If the floating element is absolute and the positioning containing block is the trigger's parent node, both move together when scrolling, and calculating coordinates once is enough.
Selects in real products almost never do this. Dropdowns typically attach to body (or outside the nearest scroll container) via getPopupContainer, for very concrete reasons:
- Select boxes often sit inside cards, tables, or modals with
overflow: hidden / auto. - The parent may also have
transformor messyz-index.
If the menu remains a child of the select box, it gets clipped as soon as it exceeds the parent box, or gets buried under other layers, showing only half. Attaching to body puts it in the same group as the page's outermost layer, floating fully on top.
The cost: the reference moves inside a local scroll container, while the floating element sits still on body. As soon as the container scrolls, the relative position between the two is stale and must be recalculated. This is the scenario for autoUpdate.
autoUpdate has nothing to do with the positioning algorithm. It only calls the update function you pass in (typically running computePosition again) when "it's time to recalculate":
const cleanup = autoUpdate(button, tooltip, () => {
computePosition(button, tooltip, { /* ... */ }).then(({ x, y }) => {
Object.assign(tooltip.style, { left: `${x}px`, top: `${y}px` });
});
});
// On unmount
cleanup();
6. autoUpdate source code: four kinds of "time to recalculate"
It listens for four categories of changes:
- scroll
- resize
- layoutShift (the page shifts position, but neither scrolled nor resized)
- Per-frame comparison (transform animation displacement)
The first two directly attach listeners: scroll on ancestor scroll containers + window, and ResizeObserver / window.resize. The callback calls update(). What's really worth examining are the latter two.
6.1 layoutShift: using IntersectionObserver to frame a box exactly around the button
Native Performance API's Layout Shift is only a performance metric and cannot tell you "where this button moved to." Polling getBoundingClientRect goes back to the old path of forcing layout.
Floating UI's approach: use IntersectionObserver to frame an observation box exactly equal to the button's current rectangle. The button is still inside the box → intersection ratio is 1; pushed out by an adjacent element → ratio drops → treated as "moved," triggering update.
The observation box is achieved by taking negative values on all four sides of rootMargin:
const rootMargin = [
rect.top, // top
root.offsetWidth - (rect.left + rect.width), // right
root.offsetHeight - (rect.top + rect.height), // bottom
rect.left, // left
].map(invertToPx).join(' ');
// All negative, result looks like "-100px -500px -200px -50px"
IntersectionObserver defaults to relative to the viewport. rootMargin expands outward (positive) or shrinks inward (negative) from the root's four sides. The set of negative margins above shrinks the observation area until it exactly coincides with the button's current getBoundingClientRect.
After this, as long as the button's position relative to the viewport changes (flex reflow, node inserted above, adjacent panel expands…), the intersection ratio will leave 1. No rAF polling, no continuous getBoundingClientRect throughout.
After observing a change and completing one update, the button is now in a new position, the old frame is invalid, and the observer must be rebuilt with the new rectangle. So this path is: intersection change → recalculate positioning → replace the frame with the new rect.
6.2 Per-frame comparison: transform animation, the fish that slips through the net
scroll / resize / IntersectionObserver all fail to cover CSS transform animations: the element visually moves, but the layout rectangle may stay unchanged, and the observer's intersection ratio also stays unchanged. In this case, the only option is to enable the animationFrame option and use rAF to compare rectangles every frame:
if (animationFrame) {
frameLoop();
}
function frameLoop() {
const nextRefRect = getBoundingClientRect(reference);
if (prevRefRect && !rectsAreEqual(prevRefRect, nextRefRect)) {
update();
}
prevRefRect = nextRefRect;
frameId = requestAnimationFrame(frameLoop);
}
This is the most expensive of the four paths and is off by default. Turn it on only when the reference is genuinely undergoing transform displacement (e.g., following a drag handle, following a moving anchor).
7. Twisting the three threads together
Looking back at the entire chain:
- Measurement: For precise coordinates at a moment, use
getBoundingClientRect; for continuously tracking position changes, preferIntersectionObserverto avoid polling. - Calculation:
computePositioncalculates the floating element's top-left corner, middleware sequentially handles spacing, flipping, translating, and arrows.detectOverflowuses the clip (intersection of all clipping ancestors) to judge overflow on four sides. - Update: scroll / resize listen directly; layout shift uses "negative rootMargin framing"; transform animation alone uses rAF per-frame comparison.
When writing your own floating components, you can self-check in this order: where does the rectangle come from → how does overflow get avoided → which layer does it attach to → what events will make the coordinates stale.