跪拜 Guibai
← Back to the summary

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:

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.

Four directions corresponding to floating top-left coordinates

Middleware runs in array order, and each step can change x / y, change placement, and stuff data into middlewareData:

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.

detectOverflow comparing clip and floating

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:

  1. Whether options render is controlled by open. Unmount when closed to avoid hidden nodes continuing to participate in layout and focus.
  2. The dropdown panel attaches to the trigger's ref via computePosition. placement typically uses 'bottom-start', with offset / flip / shift attached.
  3. value supports both controlled and uncontrolled modes. If value is passed externally, follow the external source; if not, use internal useState, the same pattern as <input>'s defaultValue.
  4. Clicking outside options closes the dropdown. Listen to document's mousedown; if the click lands outside the root node, call setOpen(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:

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:

Using a virtual span to measure cursor 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:

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:

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:

  1. scroll
  2. resize
  3. layoutShift (the page shifts position, but neither scrolled nor resized)
  4. 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:

  1. Measurement: For precise coordinates at a moment, use getBoundingClientRect; for continuously tracking position changes, prefer IntersectionObserver to avoid polling.
  2. Calculation: computePosition calculates the floating element's top-left corner, middleware sequentially handles spacing, flipping, translating, and arrows. detectOverflow uses the clip (intersection of all clipping ancestors) to judge overflow on four sides.
  3. 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.