An HTML popover is any element you mark with the popover attribute. The browser then handles the hard parts of a floating panel: it shows the element above everything else on the page, closes it when you press Escape or click outside, and returns focus to the button that opened it. Menus, info boxes and small panels used to need a script and a pile of z-index fixes. Now they need two attributes.
This tutorial builds a sort menu and an info popup, and then covers the one thing the API does not do for you: deciding where on the page the popover appears.
What you will build#
A small orders page. A "Sort" button opens a menu with three choices, and picking one re-sorts the list and closes the menu. A "Shipping info" button opens a short card. Try Escape, a click outside the panel, and the Tab key to see what the browser does by itself.
The frame is small, so open the demo in its own tab to try it at full size.
The markup#
<!-- In the <head>: <link rel="stylesheet" href="/style.css"> -->
<button id="sort-button" type="button" popovertarget="sort-menu">Sort: Newest</button>
<div class="popover menu" id="sort-menu" popover>
<button class="item" type="button" data-sort="newest" popovertarget="sort-menu" popovertargetaction="hide">Newest</button>
<button class="item" type="button" data-sort="oldest" popovertarget="sort-menu" popovertargetaction="hide">Oldest</button>
<button class="item" type="button" data-sort="price" popovertarget="sort-menu" popovertargetaction="hide">Price, low to high</button>
</div>
<button type="button" popovertarget="shipping-info">Shipping info</button>
<div class="popover card" id="shipping-info" popover>
<h2>Shipping</h2>
<p>Orders ship within two business days.</p>
<button type="button" popovertarget="shipping-info" popovertargetaction="hide">Got it</button>
</div>
<p id="status" role="status">Sorted by newest.</p>
<ul id="orders"></ul>
<script src="/script.js" defer></script>
Three attributes carry the whole feature:
popoveron the panel turns it into a popover. It stays hidden until something opens it.popovertargeton a button holds theidof the panel it controls. A click toggles that panel, with no event listener from you.popovertargetaction="hide"makes a button only close its popover. That is how the menu items and the "Got it" button close the panel they are inside.
Every button here has type="button". Inside a form, a button without a type is a submit button, and you do not want a menu item to submit anything.
The CSS#
.popover {
--w: 15rem;
inset: auto;
margin: 0;
top: var(--y, 4rem);
left: min(var(--x, 1rem), calc(100vw - var(--w) - 0.5rem));
width: var(--w);
padding: 0.4rem;
border: 1px solid #cfd3dc;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(0 0 0 / 0.2);
}
A popover has default browser styles that center it on the screen, with inset: 0 and margin: auto. That suits a dialog, but not a menu that should appear under its button. The first two lines of the rule cancel the centering. After that the panel is a normal fixed-position box, and top and left place it.
The position comes from two custom properties, --x and --y, that the script sets just before the popover opens. min() on left keeps the panel inside the screen: if the button is near the right edge, the panel is pulled back so it does not run off.
There is no z-index in this rule, and you do not need one. An open popover is drawn in the browser's top layer, above every other element, however deeply it is nested in your page. That removes a whole category of "my menu is hidden behind the header" bugs.
The JavaScript#
const orders = [
{ name: "Walnut Monitor Stand", date: "2026-10-28", price: 49 },
{ name: "Brass Desk Lamp", date: "2026-10-12", price: 79 },
{ name: "Linen Notebook", date: "2026-11-02", price: 12 },
{ name: "Cable Tray", date: "2026-09-30", price: 19 },
];
const list = document.querySelector("#orders");
const status = document.querySelector("#status");
const sortButton = document.querySelector("#sort-button");
const sorters = {
newest: { label: "Newest", fn: (a, b) => b.date.localeCompare(a.date) },
oldest: { label: "Oldest", fn: (a, b) => a.date.localeCompare(b.date) },
price: { label: "Price, low to high", fn: (a, b) => a.price - b.price },
};
function render(key) {
const { label, fn } = sorters[key];
list.replaceChildren(
...[...orders].sort(fn).map((o) => {
const li = document.createElement("li");
li.innerHTML = `<strong></strong><span>$${o.price}</span>`;
li.querySelector("strong").textContent = o.name;
return li;
})
);
sortButton.textContent = `Sort: ${label}`;
status.textContent = `Sorted by ${label.toLowerCase()}.`;
}
// The buttons inside the menu already close it (popovertargetaction="hide").
// The script only applies the choice.
document.querySelectorAll("[data-sort]").forEach((item) => {
item.addEventListener("click", () => render(item.dataset.sort));
});
// Put each popover under the button that opens it, just before it is shown.
document.querySelectorAll("[popover]").forEach((popover) => {
popover.addEventListener("beforetoggle", (event) => {
if (event.newState !== "open") return;
const invoker = document.querySelector(`[popovertarget="${popover.id}"]`);
const box = invoker.getBoundingClientRect();
popover.style.setProperty("--x", `${box.left}px`);
popover.style.setProperty("--y", `${box.bottom + 6}px`);
});
});
// A fixed popover does not follow the page, so close it when the page scrolls.
window.addEventListener(
"scroll",
() => document.querySelectorAll(":popover-open").forEach((p) => p.hidePopover()),
{ passive: true }
);
render("newest");
Most of this script is the sorting, which has nothing to do with popovers. The popover part is the last two blocks.
The beforetoggle event fires just before the popover opens or closes, and event.newState says which. When it is "open", the script measures the button that opened the popover and writes its position into --x and --y. Because this happens before the panel is shown, it never appears in the wrong place for a frame.
The scroll listener closes any open popover, found with the :popover-open selector. A popover is fixed to the window, so if the page scrolls beneath it, the panel would drift away from its button. Closing it is the simplest honest answer. The status paragraph is a live region, so the new sort order is announced to screen readers.
What the browser does for you#
The default kind, popover with no value, is called an auto popover. It has behaviors you would otherwise write by hand:
- Light dismiss. Pressing Escape or clicking outside the panel closes it.
- One at a time. Opening an auto popover closes any other one that is open, unless it is nested inside it.
- Focus returns. When the popover closes, focus goes back to the button that opened it. Press Escape in the demo and watch the Sort button take focus again.
- Top layer. It is always above the rest of the page, with no
z-index.
What it does not do is trap focus. Unlike a modal dialog, a popover leaves the rest of the page usable, and Tab can walk out of it. That is the right behavior for a menu or an info box, and the wrong one for something that must be answered first. For that job use the native dialog from the HTML dialog tutorial, which makes the page behind it inert.
If you do not want light dismiss, write popover="manual". A manual popover stays open until a button or a script closes it, and it does not close other popovers. That is the right choice for something like a toast stack, as in the toast notifications tutorial, where a message has to stay put while the visitor works.
Common problems and how to fix them#
The popover appears in the middle of the screen#
That is the browser default. Reset it with inset: auto and margin: 0, then position it as shown above. Skip the reset only when a centered panel is what you want.
Clicking the button does nothing#
Check that popovertarget holds exactly the id of the panel, and that the panel has the popover attribute. The attributes work on button elements and on button-type inputs, not on links or divs.
The popover does not close when I click outside#
It is probably popover="manual". Remove the value, or write popover="auto", to get light dismiss.
The panel does not move when the page scrolls#
It is fixed to the window, so it stays where it opened. Close it on scroll, as the demo does, or recalculate its position on scroll and resize.
It behaves like a tooltip, but opens on click#
The popover API opens on a click or key press, not on hover. A tooltip that appears on hover and focus is a different pattern. Keep short hints in plain text linked with aria-describedby, and check browser support before you rely on any newer hover trigger.
An older browser shows the panel all the time#
A browser that does not know popover treats it as an ordinary element, so the panel is visible in the page. Current versions of the major browsers support the attribute, but check your audience. Never put information that people must read only inside a popover.
I used role="menu" and now the keyboard feels broken#
role="menu" promises arrow-key navigation, Home and End, and type-ahead, and the browser will not add any of that for you. A list of ordinary buttons, as in the demo, is accurate and works with Tab. Add the menu role only if you also write the keyboard handling.
Where it stops#
The popover API gives you the layer, the dismissal and the focus rules. It does not give you placement next to a button, so you still need a few lines of positioning, as above. A mobile menu like the one in the responsive navbar tutorial can be built on the same attributes, and it is worth trying when you have a menu that opens above everything else.
