Skip to content

ADR 033 Floating Element Positioning Library

Frank Steiler edited this page Jun 19, 2026 · 3 revisions

ADR-033: Floating Element Positioning Library: Floating UI

Status

Accepted

Context

The shared SearchPicker component (client/src/components/SearchPicker/SearchPicker.tsx) renders its results dropdown into a document.body portal so it can escape the overflow clipping of modals and scroll containers (the escape-from-modal-overflow behavior introduced for issue #1601). Because the dropdown is portaled out of the input's normal flow, the component must compute and continuously maintain the dropdown's absolute on-screen position to keep it visually attached to the input.

This positioning has been hand-rolled: a requestAnimationFrame loop reads inputRef.getBoundingClientRect() every paint frame while the dropdown is open, derives top/left/width against window.innerHeight, manually flips the dropdown above the input when there is insufficient space below, and closes the dropdown when the input scrolls out of view (closeIfOutOfView).

This approach is fragile on mobile:

  • visualViewport gap. The hand-rolled math consults only the layout viewport (window.innerHeight, getBoundingClientRect). On mobile, the soft keyboard shrinks and offsets the visual viewport independently of the layout viewport. The dropdown is positioned against coordinates that no longer reflect what the user sees, so it detaches from the input — exactly the symptom reported in issue #1708.
  • Scroll coalescing. Momentum/fling scrolling coalesces scroll events, so a naive listener lags. The rAF loop (added in the prior fix #1712, merged) mitigated event coalescing but still positions against the wrong viewport, so it was insufficient.
  • Device fragility. Correctly reconciling layout viewport, visual viewport, keyboard insets, pinch-zoom, and position: fixed containing-block quirks across iOS Safari / Android Chrome is a deep, device-specific problem. Maintaining bespoke math for it is high-risk and recurring.

SearchPicker is used app-wide (Area picker, Orientation picker, vendor/work-item/household-item selectors, etc.), so this defect affects every entity selector. We need a robust, maintained positioning primitive rather than another round of hand-rolled viewport math.

Alternatives Considered

Option Approach visualViewport-aware Native binaries Maintenance burden
Floating UI (@floating-ui/react) Library-managed anchored positioning with middleware (flip, shift, size, hide) and autoUpdate Yes (handles visual viewport, scroll, resize, layout shifts) No (pure JS) Low (library owns the math)
Hand-rolled visualViewport math Extend the current rAF loop to also read window.visualViewport (offsetTop/offsetLeft/scale/height) Manual, partial No High — device-fragile, recurring bugs
CSS Anchor Positioning anchor-name / position-anchor native CSS Browser-native No Low, but insufficient browser support in 2026 (no Firefox/Safari stable); not viable yet

Evaluation

Floating UI (@floating-ui/react, recommended):

  • Purpose-built for anchoring floating elements (dropdowns, popovers, tooltips) to a reference element and keeping them attached through scroll, resize, and viewport changes.
  • autoUpdate automatically tracks the reference using ResizeObserver, IntersectionObserver, ancestor scroll, and window resize — replacing the bespoke rAF loop.
  • Middleware pipeline replaces hand-rolled logic: offset (gap), flip (above/below flip), shift (keep on-screen), size (match/cap width and height against available space), and hide (detect when the reference is clipped/off-screen).
  • FloatingPortal portals to document.body, preserving the #1601 escape-from-overflow behavior.
  • Pure JavaScript — compliant with the dependency policy (ADR-004, CLAUDE.md): no esbuild/SWC/native addons.
  • Mature and the de-facto standard for React floating elements (successor to Popper.js); React 19 compatible.

Hand-rolled visualViewport math (rejected):

  • Would require correctly composing layout-viewport rects with visualViewport.offsetLeft/offsetTop/scale/height and reconciling against position: fixed containing blocks — per-device, per-browser. This is precisely the class of bug (#1708 after #1712) we keep reintroducing. Rejected as device-fragile and high-maintenance.

CSS Anchor Positioning (rejected for now):

  • Elegant and zero-dependency, but as of 2026 lacks stable cross-browser support (Chromium-only). Not viable for an app that targets mobile Safari and Firefox. Revisit in a future ADR once support lands.

Decision

Adopt Floating UI (@floating-ui/react, pinned to 0.27.19) as the standard primitive for positioning floating/anchored elements, and use it to replace the hand-rolled portal positioning in SearchPicker.

The dependency is pinned to an exact version (no caret) per the dependency policy. Its subtree (@floating-ui/react-dom, @floating-ui/dom, @floating-ui/core, @floating-ui/utils, tabbable, scheduler) was verified to contain no platform-specific native binaries — pure JavaScript, compliant with ADR-004.

Integration shape for SearchPicker

  • useFloating({ open: isOpen, onOpenChange: setIsOpen, strategy: 'fixed', placement: 'bottom-start', middleware: [offset(4), flip({ padding: 8 }), shift({ padding: 8 }), size(...)], whileElementsMounted: autoUpdate }).
  • Wire refs.setReference to the input and refs.setFloating to the portaled dropdown; spread floatingStyles onto the dropdown.
  • Render the dropdown inside FloatingPortal (portals to document.body), preserving the #1601 escape-from-modal-overflow behavior.
  • Match the dropdown width to the reference input via the size middleware (set width from rects.reference.width in apply), replacing the manual width: dropdownRect.width.
  • Replace the manual closeIfOutOfView with Floating UI's hide() middleware so the dropdown hides when the reference is fully clipped (and reappears when it returns), rather than imperatively closing. Reverted during implementation — see Implementation Notes below. Do NOT add hide() to a SearchPicker-style component that can live inside a modal.
  • Keep the existing click-outside (mousedown on document) and Escape (keydown) handlers as-is. Floating UI's useDismiss/useInteractions are available but not adopted here to minimize behavioral and accessibility risk in a contained bug fix.
  • Preserve the existing .portalDropdown CSS (max-height: 300px, overflow-y: auto, mobile max-height: 250px, z-index) — Floating UI controls only position, not the visual styling.

This is a contained shared-component change: no schema, API contract, or data-model impact.

Implementation Notes (post-implementation, issue #1708 / PR #1714)

The original integration sketch above was refined by two findings during implementation. Both are the recommended pattern for future floating-UI consumers and supersede the corresponding bullets above.

  1. Do NOT use the hide() middleware for a portaled dropdown that can live inside a modal. hide() was specified above to replace closeIfOutOfView, but it sets the floating element's visibility: hidden when it judges the reference to be clipped/escaped relative to a clipping ancestor. When SearchPicker is rendered inside a modal with overflow: hidden, hide() interpreted the modal's clipping as the reference being hidden and blanked the dropdown — a real regression caught by tablet E2E. The fix is to omit hide() entirely: FloatingPortal (rendering to document.body) is sufficient on its own to escape modal/scroll-container overflow clipping (the #1601 behavior), and the existing click-outside + scroll-driven autoUpdate keep the dropdown attached. There is no need for an explicit "hide when clipped" middleware in this component.

  2. Gate the floating element's visibility on isPositioned to suppress the first-frame flash. Floating UI returns top: 0, left: 0 on the very first render before computePosition has run, which paints the dropdown momentarily at the top-left corner. Read isPositioned from useFloating and apply visibility: hidden until it is true:

    const { refs, floatingStyles, isPositioned } = useFloating({ /* ... */ });
    // ...
    <div
      ref={refs.setFloating}
      style={isPositioned ? floatingStyles : { ...floatingStyles, visibility: 'hidden' }}
    >

    This is the canonical Floating UI pattern for avoiding the initial-position flash and should be adopted by any future consumer that mounts the floating element conditionally.

These notes keep flip/shift/size/offset and autoUpdate exactly as specified — only hide() was dropped and the isPositioned gate added.

Consequences

Easier:

  • The recurring mobile/keyboard detachment class of bug (#1708, #1712) is handled by a maintained library that is visual-viewport-aware, instead of bespoke per-device math.
  • Anchored-position concerns (gap, flip, shift on-screen, width matching) become declarative middleware rather than hand-written rAF/getBoundingClientRect logic. (The hide()/hide-when-clipped middleware was intentionally dropped — see Implementation Notes; FloatingPortal alone provides clip-escape.)
  • The bespoke rAF loop, updateDropdownRect, dropdownTop flip math, and closeIfOutOfView are removed, reducing the component's surface area and its set-state-in-effect ESLint exceptions.
  • A reusable floating-element primitive is now available for future floating UI (tooltips, popovers, menus, autocompletes), reducing the temptation to hand-roll positioning again.

More difficult:

  • A new pinned production dependency (@floating-ui/react@0.27.19) enters the tree and must be kept current (Dependabot handles this; note the existing override discipline — bump pins in lockstep with React if React is upgraded).
  • Contributors must learn the Floating UI middleware model to reason about positioning behavior, instead of reading inline rect math.
  • Future upgrades must re-verify the subtree stays pure-JS (no native binaries) per ADR-004.

Deviation Log

Date Deviation Resolution
2026-06-16 The original integration sketch prescribed Floating UI's hide() middleware to replace closeIfOutOfView. Implementation (PR #1714, issue #1708) found hide() blanked the dropdown via visibility: hidden when SearchPicker was rendered inside a modal with overflow: hidden — a regression caught by tablet E2E. ADR amended: hide() removed from the prescribed integration. FloatingPortal alone provides clip-escape. Added Implementation Notes documenting the caveat and the isPositioned first-frame-flash gate so future consumers do not repeat the mistake. Wiki was outdated; code is correct.

Clone this wiki locally