Open a menu near the bottom of the screen, and it opens upwards. Open a tooltip at the very edge, and it slides inward instead of being cut off.
None of that is luck. It's a short pipeline, and every positioning library runs a version of it. Drag the button below into the corners.
Two rectangles and a side
Positioning starts with two rectangles, both measured with getBoundingClientRect(). The reference is the thing you clicked. The floating element is the panel.
You pick a side and an alignment, like "top, centred". That's simple arithmetic. For the top, the panel's bottom edge goes at the reference's top edge, and its centre lines up with the reference's centre. Then the offset, the small gap between them, is added on the same axis.
function place(r, f, side) {
const cx = r.x + r.w / 2 - f.w / 2;
if (side === "top") return { x: cx, y: r.y - f.h - GAP };
if (side === "bottom") return { x: cx, y: r.y + r.h + GAP };
// left and right work the same way on the other axis
}
If the screen were infinite, you'd stop here. Everything after this step exists because it isn't.
Measuring the problem
Before fixing anything, the pipeline has to measure how badly the panel doesn't fit. Floating UI calls this detectOverflow. For each edge, it gives you one number: how far that edge sticks out past the boundary. Positive means it's overflowing by that many pixels. Negative means there's room to spare.
const overflow = (p, f, b) => ({
top: b.y - p.y,
bottom: p.y + f.h - (b.y + b.h),
left: b.x - p.x,
right: p.x + f.w - (b.x + b.w),
});
The boundary isn't simply the window. By default it's the element's clipping ancestors: every parent with overflow: hidden, auto or scroll that would cut the panel off, intersected with the viewport. A dropdown inside a scrolling sidebar has to fit inside the sidebar, not the screen.
Every later step reads these four numbers. They're the red regions in the demo.
Flip, then shift
Flip answers which side. If the panel overflows on the side you asked for, try the opposite one. If that fits, or at least overflows less, use it. That's why the menu at the bottom of the screen opens upwards.
Shift answers where along that side. It slides the panel along the other axis, just enough to keep it inside the boundary plus some padding. A tooltip at the right edge moves left, but stays above its trigger.
They fix different things, and that's why they're separate. A flip is a big jump to a different side. A shift is a small slide that never changes the side.
- Place side, alignment
- Offset the gap
- Flip try the other side
- Shift slide to stay in view
- Size shrink to the room left
- Arrow point at the trigger again
- Hide if the trigger is gone
Size, arrow, hide
Size handles the case where neither side fits. Picture a long menu on a short phone screen. Flipping can't help, so the menu needs to get shorter. Size works out the space between the trigger and the boundary on the chosen side and sets that as a max-height, and the menu scrolls. Drag the trigger to the middle of the demo to see it act.
Arrow fixes a problem that shift creates. The arrow was centred on the panel, and the panel was centred on the trigger. After a shift, the panel isn't centred any more, so the arrow points at nothing. The arrow step moves it back by the same amount, so it's over the trigger's centre again. It stops before the panel's rounded corners, though, and once it hits that limit it's simply as close as it can get.
Hide comes last. When you scroll the trigger out of its container, the panel would otherwise float on, pointing at empty space. Floating UI's hide checks whether the reference is fully clipped and tells you to hide the panel.
Order is part of the design
Each step works on the result of the one before, so the order changes the answer. Floating UI's docs are specific about it: offset goes first, because everything after it needs to see the real gap. Arrow and hide go last, because they describe where the panel ended up.
Flip and shift are subtler. Shift can't move a panel to another side, and a flip throws away whatever the shift did, so you want flip to settle the side before shift settles the position. Floating UI's own rule is to put flip first for edge-aligned placements like bottom-start. For centred ones, it puts shift first, so flip doesn't react to sideways overflow that a small slide would have fixed. In the demo, flip only checks the side it's on, so it can safely go first.
Size goes after flip for the same kind of reason. Shrink first, and the panel might fit on a side flip would have rejected, at a tiny height.
In a real app, this whole pipeline runs again whenever something moves. Floating UI's autoUpdate re-runs it when any scrolling ancestor scrolls, the window resizes, or either element changes size.
The platform version
CSS can now do most of this without JavaScript. Anchor positioning became Baseline in January 2026, when Firefox 147 shipped it after Chrome 125 and Safari 26.
.trigger {
anchor-name: --menu;
}
.menu {
position: absolute;
position-anchor: --menu;
position-area: top;
margin-bottom: 10px;
position-try-fallbacks: flip-block;
}
Here's how it maps onto the pipeline:
position-area: top, a 3×3 grid around the anchorposition-try-fallbacks: flip-blockposition-visibility: anchors-visibleflip-block mirrors the position across the anchor, from above to below, and it swaps the margin with it, so the gap survives the flip. There's also flip-inline for left and right, and you can list several fallbacks, tried in order. According to MDN, the browser looks for the first one that fits entirely inside the containing block, and if none does, it keeps the original.
The shift is in the spec: if the box overflows its area but would still fit in its original containing block, "by default it will 'shift' to stay within" it. Drag the button to the side edge in the demo and watch the readout. Then turn the fallbacks off with the button near the top, and you'll see the catch: the shift works on both axes, so with nowhere to flip to, it slides the panel down over its own anchor.
It pairs naturally with the popover attribute. A popover gets an implicit anchor from the button that opens it with popovertarget, and it renders in the top layer, so no ancestor can clip it.
Where this leaves the library
If you support browsers from before 2026, you still need the JavaScript version, and you still need it for an arrow. But it's worth knowing that the library and the CSS are the same idea: measure, then fix one kind of overflow at a time, in an order that doesn't undo itself.
That's also how to debug either one. When a popover lands somewhere strange, don't ask where it went. Ask which step moved it.