Declaring Tooltip, Dropdown, and Popover Positions with CSS Alone Using CSS Anchor Positioning
When doing frontend work, you often find yourself thinking, "Can't we just do this with CSS?" — especially when building tooltips and dropdowns. You want to position something next to a button, but then you have to calculate scroll positions, flip it at the viewport edge, and deal with z-index wars, and you end up reaching for a library like Floating UI.
Now, the answer to that question is "Yes, you can." CSS Anchor Positioning entered Baseline Newly Available in December 2024 when Safari 18.2 added support, and as of August 2026 it works stably across Chrome/Edge, Firefox 131+, and Safari 18.2+ (MDN anchor-name Browser compatibility). For exact coverage numbers, check the latest figures at Can I Use — anchor-positioning.
This post covers the core APIs and walks through how to implement UI patterns like tooltips, dropdowns, and popovers declaratively without JavaScript, with code examples. And since things aren't perfect yet, we'll also look at when you still need a library.
Why Has JS Been Necessary Until Now
Structural Problems with the Old Approach
CSS position: absolute or fixed only positions elements relative to a parent or the viewport. There was no way to express "attach this right below that button" in CSS alone. To position relative to another element somewhere in the DOM tree, you had no choice but to calculate coordinates in JavaScript and inject them directly.
That's exactly what Popper.js and Floating UI solved. They read the anchor position with getBoundingClientRect(), check viewport boundaries, and recalculate when needed. Useful, but at a cost. The recalculation logic runs on the main thread, and because the anchor relationship exists only in JS, CSS changes often require JS changes too.
Bundle size varies widely depending on what you use. Per the Floating UI docs, @floating-ui/dom itself is only a few KB gzipped, but adding the React binding (@floating-ui/react) and multiple middleware makes it larger. It's safer to check exact numbers by combination on Bundlephobia.
In recent versions, Floating UI's autoUpdate combines ResizeObserver and IntersectionObserver to minimize recalculation. It no longer blindly recalculates on every scroll frame, but the fact that coordinate calculation and style updates still happen in the JS layer remains unchanged.
CSS Anchor Positioning's Approach
CSS Anchor Positioning shifts this responsibility to the browser engine. You declare in CSS "attach this element to that element in this direction," and the browser handles coordinate calculation and viewport overflow detection. No JS involvement needed.
However, the claim that "you can attach to any element" is only half true. If the anchor element is inside an overflow: hidden scroll container, or if an ancestor has transform or filter applied, the containing block and clipping context change, and results can diverge from intuition. We'll revisit this later.
Deep Dive into the Core APIs
Basic Connection: anchor-name and position-anchor
The anchor relationship is established with two properties.
.trigger-btn {
anchor-name: --my-btn;
}
.tooltip {
position: fixed;
position-anchor: --my-btn;
}The value of anchor-name must be a dashed-ident with a -- prefix. The syntax resembles CSS custom properties, but it's a separate namespace.
There's a reason to choose position: fixed. Anchor positioning works best when it operates independently of DOM parent-child relationships, and the Popover API promotes elements to the top layer, which pairs naturally with fixed. However, if an ancestor has a containing block-forming property like transform, filter, or perspective, position: fixed will be relative to that ancestor instead of the viewport — a common trap. Popover API elements get around this by being promoted to the top layer, which is exactly where anchor positioning shines.
Positioning with the anchor() Function
Use the anchor() function to position relative to a specific edge of the anchor.
.tooltip {
position: fixed;
position-anchor: --my-btn;
top: anchor(bottom);
left: anchor(left);
}You can also specify the anchor name explicitly as anchor(--my-btn bottom), but if you've set a default anchor with position-anchor, you can omit it.
position-area: Intuitive Placement with a 3×3 Grid
If writing top: anchor(bottom) every time feels tedious, position-area is more convenient. It treats the anchor as the center of a 3×3 grid divided along the block and inline axes into start/center/end, and you specify which grid area to place the element in.
.tooltip {
position: fixed;
position-anchor: --my-btn;
position-area: top; /* one cell above the anchor */
/* position-area: bottom; one cell below the anchor */
/* position-area: inline-start; one cell to the left of the anchor */
}Adding a span-* keyword on one axis expands across multiple cells. For example, block-end span-inline-start means "block-end direction below the anchor, expanding toward the anchor's inline-start side," resulting in a vertically tall dropdown positioned to the lower-left of the anchor. See MDN position-area for the full list of values.
Combining with logical properties (block-start, inline-end, etc.) naturally brings RTL support along for free.
Note: During the draft spec phase,
inset-areawas renamed toposition-area. Older tutorials may still useinset-area, but the current name isposition-area.
@position-try: Automatic Fallback When Overflowing the Viewport
You can now declare in CSS what Floating UI's flip middleware used to handle.
.tooltip {
position: fixed;
position-anchor: --my-btn;
position-area: top;
position-try-fallbacks: bottom, inline-start, inline-end;
}The values listed in position-try-fallbacks are tried in order, and the first position that doesn't overflow the viewport is used. For more complex fallback styles, define them with the @position-try at-rule.
@position-try --flip-to-bottom {
position-area: bottom;
margin-top: 8px;
margin-bottom: 0;
}
.tooltip {
position: fixed;
position-anchor: --my-btn;
position-area: top;
margin-bottom: 8px;
position-try-fallbacks: --flip-to-bottom;
}@position-try and position-try-fallbacks landed in Chromium first, and Safari and Firefox support timelines differ by engine. Check MDN @position-try Browser compatibility and MDN position-try-fallbacks for the latest status. Even as of writing (August 2026), edge-case rendering may differ subtly between browsers, so before shipping to production, it's worth visually verifying that the flip actually occurs in your target browsers.
In Practice: Implementing UI Patterns
Tooltip
The most basic pattern. Combine it with :hover or :focus-visible and you're done.
<button class="icon-btn" aria-describedby="tip">
<span aria-hidden="true">⭐</span>
</button>
<div class="tooltip" id="tip" role="tooltip">Add to favorites</div>.icon-btn {
anchor-name: --icon-btn;
}
.tooltip {
position: fixed;
position-anchor: --icon-btn;
position-area: top;
position-try-fallbacks: bottom;
margin-bottom: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.icon-btn:hover + .tooltip,
.icon-btn:focus-visible + .tooltip {
opacity: 1;
}There are two reasons to use opacity: 0 + pointer-events: none. First, display: none → display: block transitions don't animate. Second, using opacity instead of visibility: hidden keeps the element in the accessibility tree, so screen reader users referencing the tooltip content via aria-describedby still receive the information. In exchange, when the element is invisible, you must block mouse events with pointer-events: none. If you need display/content-visibility-based transitions, you can combine them with transition-behavior: allow-discrete.
When a button is near the top of the viewport, position-try-fallbacks: bottom kicks in and flips the tooltip downward.
Dropdown Menu (Combined with Popover API)
This is where it truly shines when combined with the Popover API. Escape-to-close, light dismiss, top-layer stacking, and focus management all become the browser's responsibility.
<button popovertarget="menu" class="nav-btn">Open Menu</button>
<ul id="menu" popover class="dropdown-menu">
<li><a href="#">Profile</a></li>
<li><a href="#">Settings</a></li>
<li><a href="#">Logout</a></li>
</ul>.nav-btn {
anchor-name: --nav-btn;
}
.dropdown-menu {
position: fixed;
position-anchor: --nav-btn;
/* below the anchor, expanding toward the inline-start direction */
position-area: block-end span-inline-start;
min-width: anchor-size(width);
margin-top: 4px;
padding: 4px 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
list-style: none;
}anchor-size(width) reads the trigger button's width and uses it as the dropdown's minimum width. If the button width changes, it follows automatically. Note that anchor-size() may have a different support timeline than anchor-name, so check MDN anchor-size() Browser compatibility separately for your production target browsers. In unsupported browsers, anchor positioning still works but min-width is ignored, making the dropdown narrower than expected.
Elements with the popover attribute are placed in the browser's top layer, so there's no worry about z-index conflicts.
Top-layer promotion, light dismiss, and focus trapping are handled by the Popover API; coordinate calculation and overflow fallback are handled by Anchor Positioning. The benefit of this combination is that the two specs divide responsibilities cleanly without overlapping.
Form Error Messages
Error messages attached next to an input field can also be handled with anchor positioning.
<div class="field-wrap">
<input type="email" id="email" class="input-field" aria-describedby="email-error" />
<p id="email-error" class="error-msg" role="alert">Please enter a valid email address.</p>
</div>.input-field {
anchor-name: --email-input;
}
.error-msg {
position: fixed;
position-anchor: --email-input;
position-area: inline-end;
margin-inline-start: 8px;
width: max-content;
max-width: 200px;
font-size: 0.875rem;
color: #e53e3e;
}On mobile where space to the right is limited, add position-try-fallbacks: block-end to drop it below instead.
Browser Support and Progressive Enhancement
Progressive Enhancement with @supports
/* Fallback: use static positioning in environments without anchor positioning support */
.tooltip {
position: static;
display: inline-block;
margin-inline-start: 8px;
}
@supports (anchor-name: --test) {
.tooltip {
position: fixed;
position-anchor: --my-btn;
position-area: top;
position-try-fallbacks: bottom;
margin: 0 0 8px;
}
}You could use display: none in the fallback to hide the element, but that removes the information entirely for users on unsupported browsers. It's generally safer to expose it in normal document flow with static positioning, or attach a separate JS tooltip fallback.
If you need full coverage for older browsers, consider the OddBird CSS Anchor Positioning Polyfill.
Tradeoffs: When to Use This vs. When to Use a Library
| Situation | CSS Anchor Positioning | Floating UI |
|---|---|---|
| Tooltips, dropdowns, popovers | Recommended | May be overkill |
| Cursor-relative positioning | Not possible | Required |
| Legacy browser support required | Polyfill needed | Appropriate |
| Complex scroll container clipping | Limited | More flexible |
| Bundle-size-sensitive projects | Advantageous | Additional cost depending on combination |
| Location of coordinate calculation | Browser engine | JS layer |
If I were starting a new project today, I'd default to CSS Anchor Positioning and only bring in Floating UI for cursor-based positioning or legacy browser support. Popper.js guides migration to its successor Floating UI on its official site and GitHub repository, so there's little reason to adopt it fresh.
Common Mistakes and Debugging Tips
Conflict with display: contents
Setting an element with anchor-name to display: contents will prevent the anchor from being recognized. An anchor element must have an actual layout box.
The Containing Block Trap
Using position: absolute + position-anchor requires the anchor and the positioned element to share the same containing block for predictable behavior. If something appears in an unexpected location, try switching to fixed and see if that fixes it. The ancestor transform/filter/perspective trap mentioned earlier is the same category of problem.
Using DevTools
The Chrome DevTools Elements panel visually displays anchor relationships. If you see a misaligned position, the fastest way to debug is to first check which anchor is actually being matched in the DOM and which fallback was selected.
Wrapping Up
The key is the combination of Popover API handling top-layer promotion, light dismiss, and focus management, while CSS Anchor Positioning handles coordinates and overflow fallback. Where these two specs meet, the structural complexity of "floating UI" that frameworks have hidden away in components descends to the browser platform layer. With position logic declared in CSS, you don't need to touch JS alongside design changes, and the spec itself upgrades independently of library API release cycles. Frontend UI code is getting thinner again by delegating to the browser what the browser can handle — and tooltips and dropdowns look set to be the first example of that shift.
References
- MDN — Using CSS anchor positioning
- MDN — anchor-name
- MDN — position-anchor
- MDN — position-area
- MDN — @position-try
- MDN — position-try-fallbacks
- MDN — anchor-size()
- MDN — Popover API
- Can I Use — CSS Anchor Positioning
- Floating UI Documentation
- Popper.js — Migration to Floating UI
- OddBird CSS Anchor Positioning Polyfill