Positioning a tooltip or popover relative to its trigger element used to mean reaching for a dedicated JavaScript library to handle collision detection and viewport edges, tracking scroll position, resize events, and every edge case involved in keeping a floating element visually attached to something that might itself be moving. CSS anchor positioning reached usable browser support this year, and we finally tested removing that dependency entirely across a handful of projects.
Native anchor positioning handled the vast majority of our tooltip and popover cases without a single line of positioning JavaScript, and the small number of edge cases it didn't cover were rare enough to handle with a lightweight fallback rather than keeping the full library around just for them.
The removal itself was almost anticlimactic once the CSS was in place. A typical tooltip component went from wiring up a positioning library's imperative API to nothing at all beyond the CSS declarations above:
- import { computePosition, flip, shift, offset } from '@floating-ui/dom';
-
- function positionTooltip(trigger, tooltip) {
- computePosition(trigger, tooltip, {
- placement: 'top',
- middleware: [offset(8), flip(), shift({ padding: 8 })],
- }).then(({ x, y }) => {
- Object.assign(tooltip.style, { left: `${x}px`, top: `${y}px` });
- });
- }
-
- window.addEventListener('scroll', () => positionTooltip(trigger, tooltip));
- window.addEventListener('resize', () => positionTooltip(trigger, tooltip));
+ /* positioning now lives entirely in CSS — see .tooltip-trigger / .tooltip above */No scroll or resize listener, no JavaScript bundle weight for the positioning library itself, and no per-tooltip computation running in response to every scroll event on the page. The browser's layout engine now does that work as part of layout it was already doing anyway.
The core mechanism, declaring an anchor reference in CSS and letting the browser handle the positioning math natively, covers the same collision-avoidance and viewport-awareness behavior the JavaScript library used to compute manually, but the browser does it as part of layout rather than as a separate calculation running in response to scroll and resize events after the fact. In practice, the CSS for a simple tooltip now reads like this:
.tooltip-trigger {
anchor-name: --trigger;
}
.tooltip {
position: fixed;
position-anchor: --trigger;
inset-area: top;
position-try-fallbacks: flip-block;
}The performance difference was more noticeable than we expected going in. The old library recalculated positioning on scroll and resize using JavaScript event listeners, which worked fine in isolation but added up on pages with many tooltips or popovers active at once, each one running its own positioning calculation independently. Native anchor positioning is handled by the browser's layout engine directly, with no equivalent per-element JavaScript overhead, and dropping the library measurably reduced jank on pages that had previously shown it under heavy tooltip usage.
We measured that directly on the page with the heaviest tooltip usage, a dense data table where nearly every cell could trigger one, using the browser's own performance panel to capture long tasks during a scroll:
Page: dense data table (40+ tooltip triggers visible at once)
JS library CSS anchor positioning
long tasks during scroll 14 1
scripting time (scroll) 186ms 9msThat scripting time wasn't the whole story of how the page felt, but a drop from fourteen long tasks to one during a single scroll pass tracked closely with the client's own complaint about the page feeling sluggish specifically on that view.
The edge cases that still needed a fallback were mostly around more exotic positioning scenarios, a popover that needed to reposition based on complex, non-standard trigger geometry that the current anchor positioning specification doesn't yet have a clean declarative way to express. We kept a small amount of positioning JavaScript around specifically for those cases rather than trying to force everything into the native mechanism, since a forced fit there would have cost more engineering time than it saved.
Browser support was the real gating factor for when we felt comfortable making this change, and we didn't move until support was solid enough across our clients' actual user bases to make removing the JavaScript fallback for the common case a safe default rather than a risk. For a project with an unusually old-browser-heavy user base, this migration would need a longer runway or a more careful fallback strategy than we needed here.
Removing a dependency we'd carried for years felt disproportionately satisfying given how contained the change was. Fewer dependencies means fewer version upgrades to track and fewer places a library's own bugs or breaking changes can surface unexpectedly, and this was a case where the native platform genuinely caught up to what the library used to be necessary for.