The Native Popover API: 4 Menus and Tooltips I Built Without JavaScript
- Popover attribute gives top-layer rendering and light-dismiss free
- Anchor positioning ties menus to triggers with no JavaScript
- Built 4 components: dropdown, tooltip, command menu, confirm popup
- Removed a positioning library and every z-index hack from the project
I deleted a 12KB positioning library and 40 lines of z-index guesswork from one Shopify theme last month. The replacement was two HTML attributes and a handful of CSS rules. The native popover API plus CSS anchor positioning now handles every menu, tooltip, and confirm dialog I used to wire up by hand.
Here is what I built, why it works, and where the rough edges still are.
Why The Popover Attribute Changes The Math
The first thing that surprised me was how little code it took. A popover is just an element with the `popover` attribute and a trigger that points at it with `popovertarget`. No event listeners. No state variable. No library.
Click the button, the div shows. Click outside it, the div hides. Press Escape, it hides. That is light-dismiss, and it ships for free. I used to write a document click listener for every dropdown, check if the click was inside the menu, then close it. Then I would forget the Escape key. Then I would forget to remove the listener on unmount and leak memory. All of that is gone.
The bigger win is the top layer. Anything with `popover` renders in the browser top layer, above everything else on the page, regardless of where it sits in the DOM or what `overflow: hidden` ancestors it has. This is the thing that ended my z-index wars. I had a theme where a card had `overflow: hidden` for rounded corners, and a dropdown inside it got clipped. The old fix was teleporting the menu to `document.body` with a portal, then manually positioning it. The top layer makes that entire pattern unnecessary.
There are two modes. `popover` (which defaults to `popover="auto"`) gives light-dismiss and only one auto popover open at a time. `popover="manual"` stays open until you close it in code, which I use for toasts and persistent panels. Picking the right mode up front saves a lot of confusion later.
Browser support is solid now. Chrome, Edge, Safari, and Firefox all ship it. I still add a tiny feature check for the 3 percent of traffic on old browsers, and those users get a plain inline block instead of a floating menu. Nobody complained. The fallback is uglier, not broken, which is the correct tradeoff for a progressive enhancement.
Component One And Two: Dropdown And Tooltip
The dropdown was the easy case. The trick is positioning it relative to the trigger, and that is where anchor positioning comes in. I name the trigger as an anchor, then tell the popover to attach to it.
.menu-trigger { anchor-name: --opts; }
#menu {
position-anchor: --opts;
position-area: bottom span-right;
margin-top: 6px;
}
`position-area` is the readable shorthand. `bottom span-right` means "below the trigger, spanning to the right." No `getBoundingClientRect`, no scroll listeners, no recalculating on resize. The browser keeps the menu glued to the trigger as the page scrolls. I tested it inside a sticky header and it tracked perfectly.
The part that earns its keep is `position-try`. If the menu would overflow the viewport bottom, the browser flips it above the trigger automatically.
#menu {
position-try-fallbacks: flip-block, flip-inline;
}
That one line replaced the collision-detection logic that used to be the buggiest part of every menu I shipped. On a long product page, the dropdowns near the footer now open upward without me writing a single conditional.
The tooltip was even leaner. I used `popover="manual"` paired with `popovertargetaction` and hover handling, but the cleaner version uses the new CSS-only approach where a tooltip is an anchored popover shown on focus and hover. For accessibility I keep it dismissible by Escape and reachable by keyboard, because a tooltip that only appears on mouse hover excludes keyboard users.
.tip {
position-anchor: --field;
position-area: top;
margin-bottom: 8px;
}
.tip::after {
content: "";
position: absolute;
/* little arrow pointing at the anchor */
}
One real number: my tooltip component went from 84 lines of JavaScript and CSS down to 21 lines of pure CSS plus the markup. That is roughly a 75 percent cut, and the new version has fewer edge cases because the browser owns the positioning math. If you want the deeper context on the positioning side, CSS Anchor Positioning Is Production Ready covers the five patterns I lean on most. (Verify that link exists in your index before shipping, since I keep a running list of anchor patterns there.)
Component Three: The Command Menu
This is the one I was sure would force me back into JavaScript, and it mostly did not. A command menu is a search-filtered list of actions, the kind you open with a keyboard shortcut. The popover handles the open and close. Anchor positioning handles the placement, centered near the top of the screen.
The popover attribute gives me the shell for free.
I still need JavaScript for two things: filtering the list as you type, and the keyboard shortcut to open it. Everything else (the top-layer render, the backdrop, the Escape-to-close, the click-outside dismiss) is native. So my script shrank to about 30 lines, and all of it is genuine logic rather than DOM plumbing.
The `::backdrop` pseudo-element deserves a mention. Popovers get a backdrop element you can style without adding a div.
#cmd::backdrop {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
}
I used to build that dimmed background with an absolutely positioned overlay div, then juggle its z-index against the menu. Now it is one selector and the layering is handled by the top layer.
The animation was the last piece. Popovers can animate on open and close using `@starting-style` and transitions on `display` and `overlay`, which lets the element stay in the top layer through the exit animation. This used to require a setTimeout to delay the unmount until the animation finished, a pattern that broke whenever the user clicked fast.
#cmd {
opacity: 0;
transition: opacity 0.15s, overlay 0.15s allow-discrete, display 0.15s allow-discrete;
}
#cmd:popover-open { opacity: 1; }
@starting-style {
#cmd:popover-open { opacity: 0; }
}
The `allow-discrete` keyword is the magic word that makes `display` and `overlay` animatable. Without it, the menu just snaps. I forgot it the first three times and could not figure out why my fade did nothing. Background: I covered the same display-transition trick in CSS Anchor Positioning Is Production Ready if you want the full breakdown.
For scheduling the posts where I document these builds I lean on Buffer, which keeps the publishing side off my plate while I write.
Component Four: The Confirm Popup, And The Catches
The fourth component is a small confirm popup, the "are you sure you want to delete this" prompt that appears anchored to the delete button. I used `popover="manual"` here on purpose, because I do not want a misclick outside the popup to count as dismissal when a destructive action is on the table. The user has to choose Cancel or Confirm.
Delete this item?
Notice the Cancel button uses `popovertargetaction="hide"` to close the popup declaratively, no script. The Confirm button runs the actual action. That split keeps the dangerous path explicit.
Now the catches, because there are real ones.
First, focus management. The popover API moves focus into the popover for `dialog` elements, but a plain `div popover` does not trap focus by default. For the command menu I move focus to the search input on open with a tiny `toggle` event listener. Do not skip this. A menu that opens without sending focus is unusable by keyboard.
Second, `position-area` does not yet cover every alignment case I want. Centering a popover horizontally over a wide anchor sometimes needs `justify-self: anchor-center`, which has spottier support than the rest. I keep a `@supports` check and fall back to a margin nudge.
Third, the auto-dismiss can fight you. If you nest an auto popover inside another auto popover, opening the child can close the parent unless you use the popover nesting rules correctly by placing the trigger inside the parent. I lost an hour to this before reading that the relationship is inferred from where the trigger lives, not from DOM nesting of the popovers themselves.
Fourth, animating exit needs `overlay` in the transition list or the element drops out of the top layer mid-animation and flickers behind other content. That one bit me on the confirm popup until I added it.
None of these are dealbreakers. They are the kind of edges you hit once, write down, and never hit again. My running notes on this stuff feed straight into Claude Blueprint, which is where I keep the patterns I reuse across themes.
Bottom Line
Four components: dropdown, tooltip, command menu, confirm popup. I shipped all of them with the native popover attribute and CSS anchor positioning, and I removed a 12KB library plus every z-index hack in the process. The tooltip alone dropped about 75 percent of its code. The command menu kept roughly 30 lines of genuine logic and gave the rest back to the browser.
The mental shift is the real payoff. Top-layer rendering ends clipping and stacking fights. Light-dismiss and Escape handling are free. Anchor positioning with `position-try` flips menus away from screen edges without a single conditional. I write less code, ship fewer bugs, and the components behave consistently because the browser owns the hard parts.
If you build store interfaces or any UI with floating elements, try rebuilding one menu this way before reaching for a library. Start with a plain dropdown, add the anchor, add a `flip-block` fallback, then graduate to the command menu. The patterns stack. My full set of reusable snippets and the way I keep them organized lives in Claude Blueprint if you want a head start.
This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)
Back to all articles