Building Accessible Dropdowns & Tooltips Without JS Using CSS Anchor Positioning + Popover API
If you've ever had to call getBoundingClientRect(), attach scroll event listeners, and ultimately pull in the entire Popper.js bundle just to build a single tooltip — you're not alone. I was doing the same thing until a few years ago. Creating dropdowns or context menus properly felt impossible without JavaScript.
As of 2026, that assumption no longer holds. Combining CSS Anchor Positioning with the Popover API lets you build accessible dropdowns, context menus, and custom select boxes in pure HTML/CSS — with the browser handling automatic position calculation, overflow management, focus management, and Esc-to-close. These are Baseline 2026 features (an official metric tracking cross-browser compatibility across the web platform), supported in Chrome 125+, Firefox 147+, and Safari 26, making real-world adoption a realistic prospect.
In this article, we'll cover the core concepts behind both technologies, then walk through three hands-on examples — a context menu, a custom select box, and a tooltip inside a scroll container — and take an honest look at what's possible and where you need to be careful.
Core Concepts
CSS Anchor Positioning: The Browser as Your Calculator
With the old approach, positioning a dropdown below a button meant reading the button's position and dimensions with JavaScript, then manually computing top/left values for the popup element. Any time the user scrolled or resized the window, you'd have to recalculate — and you'd have to implement flip logic yourself to keep the popup within the viewport.
CSS Anchor Positioning hands that responsibility to the browser's layout engine. You simply name an anchor element and declare that a popup element should be positioned relative to it.
/* The element acting as the anchor */
.trigger-btn {
anchor-name: --my-anchor;
}
/* The element to be positioned relative to the anchor */
.popup {
position: absolute;
position-anchor: --my-anchor;
position-area: bottom span-right; /* Below the anchor, left-aligned */
margin-block-start: 4px;
}What is
position-area? It's a property that divides the space around the anchor element into a 3×3 grid and lets you specify the region where the popup should appear using keywords. You can use directional keywords liketop centerorbottom span-right, and logical direction keywords likeblock-end inline-startare also supported — making it naturally compatible with right-to-left (RTL) layouts for languages like Arabic and Hebrew.
Worrying about clipping at viewport edges is solved with a single position-try-fallbacks declaration.
.popup {
position: absolute;
position-anchor: --my-anchor;
position-area: bottom span-right;
/* If there's not enough room below, try above; then try flipping inline */
position-try-fallbacks: flip-block, flip-inline;
}Popover API: Declarative Open/Close and Focus Management
The Popover API handles the entire lifecycle of a popover layer using only HTML attributes. An element with the popover attribute starts hidden and opens when a button connected via popovertarget is clicked.
<button popovertarget="my-popup">Open Menu</button>
<div id="my-popup" popover>
Popover content
</div>What the browser handles automatically:
| Feature | Description |
|---|---|
| Toggle | Automatically opens and closes on button click |
| Light dismiss | Automatically closes when clicking outside the popover (popover="auto") |
| Esc key | Keyboard close |
| Focus return | Automatically returns focus to the trigger button on close |
| Top Layer | Always renders on top without z-index conflicts |
What is the Top Layer? It's a rendering layer the browser manages separately from the normal DOM stack.
popoverand<dialog>elements are placed here, so they always appear on top regardless of how complex the surroundingz-indexenvironment is. Even anoverflow: hiddenparent is not an issue.
popover="auto" (the default) follows a "one open at a time" behavior where opening a new popover automatically closes others. popover="manual" is used when you want to control the popover directly with JavaScript.
Practical Examples
Example 1: A Context Menu Without JavaScript
This is a pattern that comes up frequently in real projects — a "more options" button in the top-right corner of a card component that reveals edit and delete actions. Without a positioning library, this used to be surprisingly tricky to implement.
<!-- Anchor: the "more" button -->
<button
id="menu-btn"
class="ctx-trigger"
popovertarget="ctx-menu"
aria-haspopup="menu"
>
More ···
</button>
<!-- Popover menu -->
<ul
id="ctx-menu"
popover
role="menu"
aria-labelledby="menu-btn"
>
<li role="menuitem"><a href="#">Edit</a></li>
<li role="menuitem"><a href="#">Duplicate</a></li>
<li role="menuitem" class="danger"><a href="#">Delete</a></li>
</ul>/* anchor-name should be declared in external CSS so the relationship
is visually displayed in the DevTools Anchor Inspector */
.ctx-trigger {
anchor-name: --ctx-btn;
}
#ctx-menu {
/* Reset default popover styles */
margin: 0;
padding: 4px 0;
list-style: none;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 160px;
/* Anchor positioning */
position: absolute;
position-anchor: --ctx-btn;
position-area: bottom span-right;
margin-block-start: 4px;
position-try-fallbacks: flip-block;
/* Open animation — starting values defined in @starting-style */
opacity: 0;
transform: translateY(-4px);
transition: opacity 0.15s, transform 0.15s, display 0.15s allow-discrete;
}
#ctx-menu:popover-open {
opacity: 1;
transform: translateY(0);
}
/* Starting styles the browser references when transitioning display: none → block */
@starting-style {
#ctx-menu:popover-open {
opacity: 0;
transform: translateY(-4px);
}
}| Code Point | Role |
|---|---|
popovertarget="ctx-menu" |
Connects the button to the popover. The browser tracks open/close state, and some assistive technologies recognize this automatically |
aria-haspopup="menu" |
Tells screen readers in advance that this button will open a menu |
role="menu" + aria-labelledby |
Declares to screen readers that the popover is a menu and associates it with its triggering button |
role="menuitem" |
Adding a role only to the container is not enough. Each item also needs it for screen readers to correctly understand the menu structure |
position-area: bottom span-right |
Positions below the button, expanding to the right aligned with the button's left edge |
position-try-fallbacks: flip-block |
Automatically flips to above if there's not enough room below |
@starting-style |
Defines the starting style when the popover transitions from display: none |
What is
@starting-style? It's a rule that defines the initial styles of an element the moment it first joins the render tree — such as when it transitions fromdisplay: nonetoblock. Without it,transitionhas no starting point to animate from and the animation won't work. It's a Baseline 2024 feature and is currently supported in all modern browsers.
Example 2: A Customizable Select Box
I was confused by this at first too — customizing a native <select> with CSS was practically impossible, so the convention was to rebuild everything from scratch using DIVs. In the process, accessibility often got neglected and keyboard navigation had to be implemented by hand.
Starting in Chrome 135+, a single appearance: base-select declaration lets you fully style a native <select> element.
<select class="custom-select">
<!-- Custom button to display the selected value -->
<button>
<selectedcontent></selectedcontent>
</button>
<!-- Dropdown option list -->
<option value="">Select a country</option>
<option value="kr">
<span class="flag">🇰🇷</span> South Korea
</option>
<option value="us">
<span class="flag">🇺🇸</span> United States
</option>
<option value="jp">
<span class="flag">🇯🇵</span> Japan
</option>
</select><selectedcontent> is a new HTML element introduced in 2026. It might look like a typo at first glance, but it's an officially recognized new element that mirrors the content of the currently selected <option> in real time. Since it reflects rich markup like emoji flags — not just text — there's no longer any need for JavaScript to separately render the selected value.
.custom-select {
/* Unlock native select styling */
appearance: base-select;
}
/* Customize the default arrow icon */
.custom-select::picker-icon {
content: "▾";
color: #6b7280;
}
/* Style the dropdown picker (list container) */
.custom-select::picker(select) {
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
padding: 4px 0;
/* Anchor positioning like position-area is handled automatically by the browser */
}
/* Individual option styles */
.custom-select option {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.custom-select option:hover,
.custom-select option:checked {
background: #eff6ff;
color: #1d4ed8;
}
/* Fall back to the default native select in environments without Firefox/Safari support */
@supports not (appearance: base-select) {
.custom-select {
appearance: auto;
/* This is a minimal fallback example. If you used custom border, border-radius, etc.,
you'll need to revert those here as well to properly match the native appearance */
}
}Internally, the Popover API and Anchor Positioning handle dropdown placement, but developers don't need to worry about that. However, since this is currently only supported in Chrome 135+, fallback handling for Firefox and Safari users is still required.
Example 3: A Tooltip Inside a Scroll Container
When a trigger is inside a scrollable container, using position: absolute can cause the popup to be clipped by an overflow: hidden parent. In these situations, position: fixed calculates position relative to the viewport and always renders correctly.
To be upfront: this example does require a small amount of JavaScript. Tooltips typically appear on hover, and popover="manual" mode cannot be opened without JS — and CSS :hover cannot trigger the Popover API. That said, CSS Anchor Positioning handles all the position calculation, making it significantly simpler than the traditional getBoundingClientRect() + scroll event combination.
<span
class="tooltip-trigger"
aria-describedby="my-tooltip"
tabindex="0"
>
Help <span aria-hidden="true">ⓘ</span>
</span>
<!-- popover="manual": JS directly controls open and close -->
<div id="my-tooltip" role="tooltip" class="tooltip" popover="manual">
This field is required.
</div>.tooltip-trigger {
anchor-name: --tooltip-anchor;
}
.tooltip {
position: fixed; /* Ignores overflow parents, positioned relative to viewport */
position-anchor: --tooltip-anchor;
position-area: top center;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
background: #1e293b;
color: #f8fafc;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.875rem;
max-width: 240px;
margin-block-end: 6px;
opacity: 0;
transition: opacity 0.15s, display 0.15s allow-discrete;
}
.tooltip:popover-open {
opacity: 1;
}
@starting-style {
.tooltip:popover-open {
opacity: 0;
}
}// Hover trigger — JS is only responsible for this signal
const trigger = document.querySelector('.tooltip-trigger');
const tooltip = document.getElementById('my-tooltip');
trigger.addEventListener('mouseenter', () => tooltip.showPopover());
trigger.addEventListener('mouseleave', () => tooltip.hidePopover());
trigger.addEventListener('focus', () => tooltip.showPopover());
trigger.addEventListener('blur', () => tooltip.hidePopover());Position calculation, viewport overflow handling, and directional flipping are all handled by CSS — JS only sends the open and close signals. You'll notice a meaningful reduction in code volume compared to the traditional approach.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Bundle size reduction | Positioning libraries like Popper.js (~3KB gzip) and Floating UI (~5KB gzip) can be removed, reducing initial load time |
| Built-in browser accessibility | Esc-to-close and focus return are handled automatically just by connecting popovertarget |
| Automatic overflow handling | Flip and slide behavior at viewport boundaries is solved with a single position-try-fallbacks CSS declaration |
| Top Layer rendering | Always renders on top regardless of DOM position or z-index context, eliminating stacking conflicts |
| Performance | Positioning calculations are handled by the browser's layout engine rather than JavaScript loops, resulting in better scroll and resize performance |
There were also things that disappointed me more than expected when using these in practice.
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Accessibility completeness is still your responsibility | The browser handles Esc and focus return, but ARIA structure like role="menu", aria-haspopup, and role="menuitem" must be declared manually |
Follow the ARIA APG menu pattern and carefully add all required attributes |
| No keyboard navigation support | Arrow key navigation between menu items and Tab-to-exit behavior as required by ARIA menu patterns still need a small amount of JS | Clearly understand the limits of pure CSS and add only the minimum JS required |
| Customizable Select support incomplete | appearance: base-select is only supported in Chrome 135+ |
Use @supports for fallback, or run a JS select library in parallel as a progressive enhancement strategy |
| No right-click context menu | A true context menu that responds to right-click events requires a contextmenu event listener |
Remember that CSS Anchor Positioning only handles placement |
| Animation requires additional setup | Enter and exit animations for popovers require @starting-style and the allow-discrete keyword to be written separately |
Use the pattern shown in the example code as a boilerplate baseline |
The accessibility completeness issue was the one I encountered most often in real-world usage. It's easy to skip ARIA attributes when you expect the browser to handle so much automatically, but this becomes immediately apparent when testing with a screen reader.
The Most Common Mistakes in Practice
- Declaring
anchor-namein inline styles — Inline declarations likestyle="anchor-name: --my-anchor"do work, but declaring in external CSS allows Chrome DevTools' Anchor Positioning Inspector to visually display anchor relationships, making debugging much easier. - Declaring
role="menu"on the popover withoutrole="menuitem"on the items — For screen readers to recognize the menu structure correctly, the correct role must be present on each item, not just the container. - Choosing the wrong
position: fixedvsabsolute— If the trigger is inside a scrollable parent or anoverflow: hiddencontainer, you must useposition: fixedto prevent the popup from being clipped. Usingabsolutecan cause issues with the initial containing block calculation even when the popup is in the top layer.
Closing Thoughts
After building all three examples hands-on, the core insight behind both technologies became clear: "position calculation belongs to the browser; accessibility structure belongs to the developer." The most tedious part that positioning libraries used to handle is now replaced by a few lines of CSS declarations, and component code becomes noticeably simpler. The customizable select still requires cross-browser handling, but the context menu is ready for production use today.
Three steps you can take right now:
- Check your project's browser analytics — Look at the visitor browser version breakdown in your analytics tool. If a sufficient share of users are on Chrome 125+, Firefox 147+, or Safari 26 and above, you can consider adopting these features. Wrapping code in
@supports (anchor-name: --x) {}ensures existing implementations remain intact in unsupported browsers. - Replace one existing tooltip in your project — If you already have a Floating UI or custom JS tooltip, try swapping the simplest one for the
anchor-name+position-area+popovercombination. The difference in code volume before and after will be immediately visible. - Review the ARIA APG Menu pattern checklist once — The ARIA Authoring Practices Guide's Menu pattern will help you draw a clear line between what CSS covers and what still requires JS, giving you a realistic scope for implementation based on your project's requirements.
If you need a polyfill for environments that don't support CSS Anchor Positioning, you can use Oddbird's CSS Anchor Positioning Polyfill. Alternatively, detecting support with @supports (anchor-name: --x) and loading Floating UI only in unsupported environments is also a solid progressive enhancement strategy.
References
- Popover Context Menus with Anchor Positioning | Frontend Masters Blog
- Anchor Positioning and the Popover API for a JS-Free Site Menu | CSS { In Real Life }
- Using CSS anchor positioning | MDN Web Docs
- Introducing the CSS anchor positioning API | Chrome for Developers
- CSS Anchor Positioning Module Level 1 | W3C TR
- First Public Working Draft: CSS Anchor Positioning Module Level 2 | W3C News
- Anchor Positioning Updates for Fall 2025 | OddBird
- Using the Popover API | MDN Web Docs
- Popover API lands in Baseline | web.dev
- Menus, toasts and more with the Popover API, dialog, invokers, anchor positioning | Frontend Masters Blog
- On popover accessibility: what the browser does and doesn't do | hidde.blog
- Let's build an Accessible Menu with Modern Web Features | oidaisdes.org
- The
<select>element can now be customized with CSS | Chrome for Developers - Customizable select elements | MDN Web Docs
- CSS Anchor Positioning Guide | CSS-Tricks
- A gentle introduction to anchor positioning | WebKit Blog
- Invoker Commands Explainer | Open UI