An ongoing series on techniques that ship with the browser already - no library, no build step required.
A menu that floats above the page sounds simple until you actually build one. It needs open and closed state. It needs to close when someone clicks anywhere else. It needs to render above everything else on the page, which sooner or later means a fight with some unrelated element’s z-index. Historically, all three were your problem to solve.
The popover attribute solves all three at once, and it’s a single word in your HTML. Open this one, then click anywhere outside it:
Actions
HTML
<div class="pop-wrap">
<button popovertarget="pop-menu-demo" class="pop-btn">
Actions <span aria-hidden="true">▾</span>
</button>
<p class="pop-fallback-label">Actions</p>
<div id="pop-menu-demo" popover class="pop-menu">
<button type="button" popovertarget="pop-menu-demo" popovertargetaction="hide">Duplicate</button>
<button type="button" popovertarget="pop-menu-demo" popovertargetaction="hide">Rename</button>
<button type="button" popovertarget="pop-menu-demo" popovertargetaction="hide">Delete</button>
</div>
</div>CSS
.pop-wrap {
display: inline-block;
}
.pop-btn {
anchor-name: --pop-anchor;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.1rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-gray-900);
background: var(--color-white);
border: 1px solid var(--color-gray-300);
cursor: pointer;
}
.pop-btn:hover {
border-color: var(--color-gray-900);
}
.pop-fallback-label {
display: none;
margin: 0 0 0.5rem;
padding: 0.6rem 1.1rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-gray-500);
border: 1px dashed var(--color-gray-300);
}
.pop-menu:popover-open {
position-anchor: --pop-anchor;
top: calc(anchor(bottom) + 6px);
left: anchor(left);
margin: 0;
padding: 0.4rem;
display: flex;
flex-direction: column;
min-width: 160px;
border: 1px solid var(--color-gray-200);
background: var(--color-white);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.1);
}
.pop-menu:popover-open button {
all: unset;
padding: 0.5rem 0.6rem;
font-size: 0.85rem;
color: var(--color-gray-600);
cursor: pointer;
}
.pop-menu:popover-open button:hover,
.pop-menu:popover-open button:focus-visible {
background: var(--color-gray-50);
color: var(--color-gray-900);
}
.pop-menu:popover-open {
animation: pop-in 0.12s ease-out;
}
@keyframes pop-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* No popover support: there's nothing for a trigger to toggle, so drop the
dead button and show the menu as a plain, permanently-visible list. */
@supports not selector(:popover-open) {
.pop-btn {
display: none;
}
.pop-fallback-label {
display: block;
}
.pop-menu {
position: static;
box-shadow: none;
}
}JS
// Belt-and-suspenders for this live demo only - not part of the technique
// itself. Escape and outside-click are supposed to close an "auto" popover
// natively; this just makes sure they still do it even in a browser whose
// popover implementation is present but shakier than it should be.
(function () {
var menu = document.getElementById("pop-menu-demo");
var trigger = document.querySelector(".pop-btn");
if (!menu || !trigger || typeof menu.hidePopover !== "function") return;
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && menu.matches(":popover-open")) {
menu.hidePopover();
}
});
document.addEventListener("click", function (e) {
if (
menu.matches(":popover-open") &&
!menu.contains(e.target) &&
!trigger.contains(e.target)
) {
menu.hidePopover();
}
});
menu.querySelectorAll("button[popovertargetaction='hide']").forEach(function (btn) {
btn.addEventListener("click", function () {
if (menu.matches(":popover-open")) menu.hidePopover();
});
});
})();The open/close mechanism above is still just the two attributes - nothing was hand-rolled to make the toggle happen. What the live demo adds on top is a few defensive lines that force Escape, an outside click, and an item click to close the menu even if a particular browser’s native handling of any one of those turns out to be unreliable. Redundant when the native behavior already works, a safety net when it doesn’t.
The whole idea
Two attributes, one shared id:
<button popovertarget="menu">Actions</button>
<div id="menu" popover>
<button popovertarget="menu" popovertargetaction="hide">Duplicate</button>
<button popovertarget="menu" popovertargetaction="hide">Delete</button>
</div>
popover marks the <div> as a popover, which starts hidden by default. popovertarget on the trigger button points at its id and toggles it - open if closed, closed if open. That’s the whole mechanism for showing and hiding it. On the menu items, popovertargetaction="hide" overrides the toggle so clicking one always closes the menu, which is what you want from a menu item whether or not it also does something else.
Two things happen that you never asked for and never wrote. The popover renders in the top layer - a rendering layer that sits above the entire rest of the document, so there’s no z-index to negotiate with the rest of your page, ever. And it’s light-dismissible: click anywhere outside it, or press Escape, and the browser closes it on its own. Recreating that second part by hand is a surprisingly fiddly bit of JavaScript - a document-level click listener that has to check whether the click landed inside or outside the menu, careful not to also catch the click that opened it.
The attribute itself has been shipping for a couple of years now:
Browser support · popover attribute
Support data from MDN’s browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.
Putting it where you actually want it
Left alone, a popover’s default position is centered over the page - fine for something like a big announcement, wrong for a menu that should hang off its button. The instinct is to reach for the usual trick: wrap the button in position: relative, give the popover position: absolute, done. It doesn’t work here. The top layer is what makes a popover render above everything else with no z-index fuss, but it also lifts the element out of the normal containing-block chain - so once something is a popover, an ordinary position: relative ancestor no longer has any say in where it sits.
The feature built to solve exactly this is CSS anchor positioning. Name the trigger as an anchor, then position the popover against that name instead of against an ancestor:
.trigger {
anchor-name: --menu-anchor;
}
[popover] {
position-anchor: --menu-anchor;
top: calc(anchor(bottom) + 6px);
left: anchor(left);
}
anchor-name tags the button. position-anchor on the popover points at that tag, and the anchor() function reads a specific edge of it - here, “6px below the button’s bottom edge” and “aligned with its left edge.” The popover never needs position: relative on anything; it stays in the top layer the whole time, which is what makes the z-index-free part work in the first place.
This is the newest technique in this collection, and it shows - anchor positioning trails the popover attribute above by a year or more in every engine, and Safari only caught up very recently:
Browser support · CSS anchor positioning
Support data from MDN’s browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.
So it’s worth being honest about what happens when a piece of it isn’t there. There are two separate things that can fail, and they fail differently:
A browser that supports popover but not anchor() just can’t resolve top: calc(anchor(bottom) + 6px) and left: anchor(left) - those two declarations are invalid, so they’re dropped, and the popover falls back to its default centered position. Nothing else breaks: it still opens, closes, light-dismisses, and traps nothing. Centered isn’t wrong, just less tailored. That fallback is free - there’s nothing extra to author for it.
A browser that doesn’t support popover at all is a bigger gap, because the whole toggle-and-dismiss mechanism goes with it, not just the placement. Styling alone can’t fake that behavior back in, so this demo doesn’t try to - it detects the gap with @supports not selector(:popover-open) and swaps to something honest instead: the trigger button disappears, since there’d be nothing for it to do, and the menu renders as a plain, permanently-visible list in the normal flow of the page. Still fully readable and usable, just not floating and not collapsible. That’s the one fallback in this post that has to be authored on purpose rather than arriving for free - which is the right trade for a feature this new.
Where <dialog> still wins
popover isn’t a replacement for every floating element. It doesn’t trap focus inside itself and it doesn’t make the rest of the page inert - which is exactly right for a menu, a tooltip, or a toast, and exactly wrong for a confirmation dialog someone has to deal with before doing anything else. That second case is what <dialog> is for, and - as of recently - opening one as a true modal no longer needs a line of JavaScript either: a command="show-modal" attribute paired with commandfor does for <dialog> what popovertarget does for a popover. More on that, and what a true modal buys you over a popover. Reach for popover by default, and reach for <dialog> the day you genuinely need to block the rest of the page.
If <details> is the platform’s native answer to a disclosure widget, popover is its answer to a floating one. Between the two, most of the menus, tooltips, and toggles a typical site needs no longer have a good reason to ship a component library.



