Ship tethered tooltips, dropdowns, and context menus with native CSS Anchor Positioning instead of scroll listeners, computePosition(), and edge math.

Floating UI and Popper.js became default dependencies because CSS couldn’t keep a floating box attached to its trigger once scrolling, resizing, and viewport edges entered the picture. That gap is gone: css anchor positioning shipped across Chrome, Safari, and Firefox in early 2026, which means a lot of tooltip and dropdown logic can move back into CSS. If you want to sanity-check edge cases while you read, keep the Viewport Debugger open in another tab.
The pain isn’t drawing a tooltip. It’s everything after that.
Imagine a settings button near the bottom of the screen. You click it, open a dropdown, then the user zooms, scrolls a container, rotates a phone, or triggers the on-screen keyboard. Your “simple” floating box now needs to:
That’s the job description that made Floating UI’s core package explode to roughly 23 million weekly downloads. Most installs exist to solve the same problem: calculate coordinates, apply top/left, listen for environmental changes, and do it again.
A typical old tooltip implementation looked like this:
const btn = document.querySelector('#help-btn');
const tip = document.querySelector('#help-tip');
function updatePosition() {
const rect = btn.getBoundingClientRect();
tip.style.top = (rect.top + window.scrollY - tip.offsetHeight - 8) + 'px';
tip.style.left = (rect.left + rect.width / 2 - tip.offsetWidth / 2) + 'px';
}
btn.addEventListener('mouseenter', () => { tip.hidden = false; updatePosition(); });
btn.addEventListener('mouseleave', () => { tip.hidden = true; });
window.addEventListener('scroll', updatePosition);
window.addEventListener('resize', updatePosition);Or a dropdown using Floating UI:
import { computePosition, autoPlacement } from '@floating-ui/dom';
computePosition(trigger, menu, {
middleware: [autoPlacement()],
}).then(({ x, y }) => {
Object.assign(menu.style, { left: x + 'px', top: y + 'px' });
});
// Plus scroll/resize cleanup...That code works. It also exists mostly because the platform used to be missing one primitive: “attach this box to that element.” CSS anchor positioning adds exactly that primitive.
There are only two steps:
anchor-nameposition-anchor, then place it using position-area or anchor()The floating element must use position: absolute or position: fixed.
/* Step 1: Name the anchor */
.trigger {
anchor-name: --my-trigger;
}
/* Step 2: Tether the floating element */
.tooltip {
position: absolute;
position-anchor: --my-trigger;
position-area: top center;
}That’s the core of css anchor positioning. position-area handles the common 3×3 placements quickly, while anchor() gives you edge-based control when you need precision. For the patterns most teams install Floating UI for, that’s enough to get moving.
getBoundingClientRect() or scroll listenersThis is the cleanest win because the browser can handle both tethering and fallback placement.
Here’s the complete tooltip pattern using the Popover API for show/hide and anchor positioning for placement:
<!-- HTML -->
<button
id="help-btn"
popovertarget="help-tip"
aria-describedby="help-tip"
>
Help
</button>
<div
id="help-tip"
role="tooltip"
popover="hint"
>
Click for documentation
</div>/* CSS */
#help-btn {
anchor-name: --help-btn;
}
#help-tip {
position: absolute;
position-anchor: --help-btn;
position-area: top center;
position-try-fallbacks: flip-block;
margin-bottom: 8px;
/* Reset popover default margins */
inset: auto;
}/* JS this replaces:
const btn = document.querySelector('#help-btn');
const tip = document.querySelector('#help-tip');
function updatePosition() {
const rect = btn.getBoundingClientRect();
tip.style.top = (rect.top + window.scrollY - tip.offsetHeight - 8) + 'px';
tip.style.left = (rect.left + rect.width / 2 - tip.offsetWidth / 2) + 'px';
}
btn.addEventListener('mouseenter', () => { tip.hidden = false; updatePosition(); });
btn.addEventListener('mouseleave', () => { tip.hidden = true; });
window.addEventListener('scroll', updatePosition);
window.addEventListener('resize', updatePosition); */A more production-ready version with styling looks like this:
<button
id="help-btn"
class="help-button"
popovertarget="help-tip"
aria-describedby="help-tip"
>
Help
</button>
<div
id="help-tip"
class="tooltip"
role="tooltip"
popover="hint"
>
Click for documentation
</div>.help-button {
anchor-name: --help-btn;
padding: 0.65rem 0.9rem;
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
background: white;
color: #0f172a;
font: 600 14px/1.2 system-ui, sans-serif;
}
.tooltip {
position: absolute;
position-anchor: --help-btn;
position-area: top center;
position-try-fallbacks: flip-block;
margin-bottom: 8px;
inset: auto;
max-width: 20ch;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: #0f172a;
color: white;
font: 500 13px/1.4 system-ui, sans-serif;
border: 0;
}What changed? You removed coordinate math, scroll listeners, and resize listeners. The browser now owns the tethering logic and flips the tooltip below the button when there isn’t room above.
This is where teams usually reach for computePosition() immediately. With anchor positioning, the browser can place the panel and size it from the trigger in one pass.
/* CSS */
.menu-trigger {
anchor-name: --menu-trigger;
}
.dropdown-menu {
position: absolute;
position-anchor: --menu-trigger;
position-area: bottom span-all;
width: anchor-size(width); /* matches trigger width exactly */
position-try-fallbacks: flip-block;
inset: auto;
}/* JS this replaces (Floating UI):
import { computePosition, autoPlacement } from '@floating-ui/dom';
computePosition(trigger, menu, {
middleware: [autoPlacement()],
}).then(({ x, y }) => {
Object.assign(menu.style, { left: x + 'px', top: y + 'px' });
});
// Plus scroll/resize cleanup... */Complete example:
<button class="menu-trigger" popovertarget="account-menu">
Account
</button>
<div id="account-menu" class="dropdown-menu" popover>
<button type="button">Profile</button>
<button type="button">Billing</button>
<button type="button">Sign out</button>
</div>.menu-trigger {
anchor-name: --menu-trigger;
width: 220px;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
background: #fff;
text-align: left;
font: 600 14px/1.2 system-ui, sans-serif;
}
.dropdown-menu {
position: absolute;
position-anchor: --menu-trigger;
position-area: bottom span-all;
width: anchor-size(width); /* matches trigger width exactly */
position-try-fallbacks: flip-block;
inset: auto;
margin-top: 8px;
padding: 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
background: #fff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12);
}
.dropdown-menu button {
display: block;
width: 100%;
padding: 0.7rem 0.75rem;
border: 0;
border-radius: 0.5rem;
background: transparent;
text-align: left;
font: 500 14px/1.2 system-ui, sans-serif;
}
.dropdown-menu button:hover {
background: #f8fafc;
}The useful bit here is anchor-size(width). That single declaration replaces the annoying “measure trigger width, then apply it to the menu” step that often sneaks into JavaScript.
This is also the pattern where viewport edges show up fastest. Put the trigger near the bottom of the screen, open the menu, and verify that position-try-fallbacks: flip-block sends it upward. That’s a good moment to test with the Viewport Debugger, especially on mobile-sized viewports where browser UI and safe areas make placements feel “off” even when your CSS is technically correct.
This is the one pattern that still needs JavaScript, but much less of it.
The trick is to create a 1×1 invisible anchor element, place it at the pointer coordinates with CSS variables, and anchor the context menu to that element. The browser still handles the tethering and flipping.
/* CSS */
.cursor-anchor {
position: fixed;
top: var(--cursor-y);
left: var(--cursor-x);
width: 1px;
height: 1px;
anchor-name: --cursor;
pointer-events: none;
}
.context-menu {
position: fixed;
position-anchor: --cursor;
position-area: bottom right;
position-try-fallbacks: flip-block, flip-inline;
inset: auto;
}/* JS — just this, nothing more: */
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
document.documentElement.style.setProperty('--cursor-x', e.clientX + 'px');
document.documentElement.style.setProperty('--cursor-y', e.clientY + 'px');
menu.showPopover();
});/* JS this replaces (the old way):
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
menu.style.display = 'block';
const { x, y } = computePosition(e); // custom logic
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// + scroll listener + resize listener + viewport edge math... */Here’s the complete runnable pattern:
<div class="workspace">
Right-click anywhere in this box
</div>
<div class="cursor-anchor" aria-hidden="true"></div>
<div id="context-menu" class="context-menu" popover>
<button type="button">Copy</button>
<button type="button">Rename</button>
<button type="button">Delete</button>
</div>
<script>
const menu = document.querySelector('#context-menu');
const workspace = document.querySelector('.workspace');
workspace.addEventListener('contextmenu', (e) => {
e.preventDefault();
document.documentElement.style.setProperty('--cursor-x', e.clientX + 'px');
document.documentElement.style.setProperty('--cursor-y', e.clientY + 'px');
menu.showPopover();
});
document.addEventListener('click', (e) => {
if (!menu.contains(e.target)) {
menu.hidePopover();
}
});
</script>:root {
--cursor-x: 0px;
--cursor-y: 0px;
}
.workspace {
min-height: 240px;
padding: 1rem;
border: 2px dashed #cbd5e1;
border-radius: 1rem;
background: #f8fafc;
font: 500 14px/1.4 system-ui, sans-serif;
color: #334155;
}
.cursor-anchor {
position: fixed;
top: var(--cursor-y);
left: var(--cursor-x);
width: 1px;
height: 1px;
anchor-name: --cursor;
pointer-events: none;
}
.context-menu {
position: fixed;
position-anchor: --cursor;
position-area: bottom right;
position-try-fallbacks: flip-block, flip-inline;
inset: auto;
min-width: 180px;
padding: 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
background: #fff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
}
.context-menu button {
display: block;
width: 100%;
padding: 0.7rem 0.75rem;
border: 0;
border-radius: 0.5rem;
background: transparent;
text-align: left;
font: 500 14px/1.2 system-ui, sans-serif;
}
.context-menu button:hover {
background: #f1f5f9;
}If you want more precise offsets later, anchor() is available. But for most context menus, position-area plus flip fallbacks already kills off the hardest part of the old implementation.
The main trade-off is simple: this is ready for a lot of new work, but you still need a fallback strategy for non-supporting browsers and some judgment about complexity.
As of early 2026, support is solid: Chrome 125+, Safari 26 (Sep 2025), and Firefox 147 (Jan 2026) all ship it, which puts global availability around 76%. For plenty of internal tools, SaaS dashboards, and modern-browser consumer apps, that’s already enough. For broader compatibility, wrap the enhanced version in @supports and keep a basic absolute-positioned fallback outside it.
/* Fallback — all browsers */
.tooltip {
position: absolute;
top: -2rem;
left: 50%;
transform: translateX(-50%);
}
/* Enhanced — anchor positioning browsers */
@supports (anchor-name: --test) {
.trigger { anchor-name: --my-trigger; }
.tooltip {
position: absolute;
position-anchor: --my-trigger;
position-area: top center;
position-try-fallbacks: flip-block;
inset: auto;
top: unset;
left: unset;
transform: none;
}
}A few practical notes:
position-area covers a lot, but not every bespoke floating interaction. Complex collision strategies may still justify JavaScript.For new projects in 2026, there’s usually no reason to install Floating UI or Popper.js just to build a basic tooltip, dropdown, or context menu. Name the anchor, tether the floating element, add flip fallbacks, and let the browser handle the part you used to reimplement in JavaScript. Then test the awkward viewport cases in the Viewport Debugger, because that’s where native anchor positioning really earns its keep.