Definition
Reach for this concept when a small UI (popover/toolbar) that floats next to selected text or next to a clicked button drifts away from where it was pinned and gets stranded in the wrong place when you scroll the page while it's open.
Fixed-position floating UI scroll drift is the phenomenon where a UI pinned to a selection/click position holds a top/left (or getBoundingClientRect — a browser function that measures an element's on-screen position and size) captured only once, when opening, against a position: fixed (CSS layout fixed relative to the screen viewport so it doesn't move on scroll) anchor (an invisible element that serves as the reference point the floating UI attaches to). As the user scrolls, the body content moves but the anchor stays at its past coordinates in the viewport (the area actually visible on screen in the browser), so the popover/format toolbar drifts off the highlight.
Why it matters
Popper/Radix/Floating UI align content to the anchor's current rect (the position/size rectangle of an element). If the anchor coordinates are stale (aged and out of sync with reality), the library dutifully follows the "precisely" wrong position. selectionchange (an event fired when the user changes the text selection region) does not cause a scroll, so selection-based UI and scroll tracking must be handled separately.
How it works
- On toolbar open, measure the selection/caret position and place an invisible fixed anchor.
- The user scrolls the window or a nested
overflow: autocontainer. - Text/highlight moves in the document coordinate system, but the anchor's
top/leftis not updated. - The floating layer is laid out based on the stale anchor rect.
- The
scrollevent does not bubble (an event propagating upward from child to parent), but in the capture phase (the stage that descends from parent to child and intercepts the event first) you can catch even nested scrollers with a window listener.
Practical application
- Register and clean up the scroll(capture) + resize listeners only while the UI is open.
- When pinning to a specific element like a link popover, keep the ref of the clicked DOM node and re-measure with
getBoundingClientRect(); close when the node is removed. - If the rect is unchanged from before, skip setState to reduce unnecessary re-renders.
- If jank (scrolling/animation being choppy and stuttering rather than smooth) appears on long documents, consider
requestAnimationFrame(a browser API that runs a callback right before the next screen paint) throttle (limiting call frequency). - jsdom test: verify coordinate tracking with a layout API mock +
fireEvent.scroll(window).
Trade-offs
- Measuring on every scroll is often cheap, but with thousands of simultaneously open UIs the cost adds up — keep listeners only while open.
- Capture scroll fires on every scroller, so keep the handler lightweight.
- "Close on scroll" is easy to implement but is a different UX — if the requirement is "follow along," re-measurement is the right choice.
When not to use
- Global headers/toasts fixed to the viewport — not a selection anchor.
- When absolute positioning outside the scroll container already tracks document coordinates.
- The latest API where the browser tracks via native
position: fixed+ CSSanchor-name(in supporting environments) — needs polyfill/target-browser checks.
Common mistakes
- Listening only to
selectionchangeand ignoring scroll. - Registering only bubble-phase scroll and missing nested scrollers.
- Measuring once on open and mistaking it for a "bug" in Radix.
- Verifying only in production when coordinates are 0 in a test environment that has no layout.
Related concepts
- selectionchange-floating-ui — selection UI trigger
- radix-portal-scoped-css — Portal/scoped CSS (a separate issue)
- html5-drag-and-drop — mouseup suppression and selection UI