The popover attribute turns any element into a layer that opens on top of the page. Add popover to a div, point a button at it with popovertarget, and the browser handles showing, hiding, stacking in the top layer, closing on Esc and closing when you click outside. No z-index battles and, for the common cases, no JavaScript.

That covers a large share of what we used to build with plugins: dropdown menus, toasts, tooltips, flyout panels and bottom sheets. The API also has three modes (auto, manual and hint), a JavaScript side (showPopover, hidePopover, togglePopover and a toggle event) and pairs well with CSS anchor positioning and @starting-style for placement and animation.

The eight demos below each solve one real interface problem, explain the lines that matter, and keep working in browsers that lack a feature. We also compare popover with the dialog element, because choosing between a modal and a non-modal layer is the decision that matters most for accessibility.

The popover attribute in one minute

Values: popover or popover="auto" gives a popover that light-dismisses (outside click or Esc) and closes other auto popovers that aren't its ancestors. popover="manual" never closes on its own and any number can be open at once. popover="hint" is for tooltips: it closes other hints but leaves open auto popovers alone. An unknown value, including hint in a browser that doesn't know it, is treated as manual.

Declarative triggers: a button with popovertarget="id" toggles the popover; popovertargetaction="show" or "hide" makes it one-way. The newer invoker commands do the same with commandfor="id" and command="toggle-popover", "show-popover" or "hide-popover", and also drive dialog elements with show-modal and close.

JavaScript: el.showPopover(), el.hidePopover() and el.togglePopover() (which returns the new state), plus beforetoggle and toggle events whose newState is "open" or "closed". In CSS, :popover-open matches an open popover and ::backdrop styles the layer behind it.

Two defaults to know: the user-agent stylesheet centres popovers with position: fixed; inset: 0; margin: auto, and hides closed ones with display: none. Set display on an inner wrapper rather than on the popover itself, or you will override the hiding rule and the popover will always show.

1. A dropdown menu with no JavaScript

The account menu is a nav element with the popover attribute, opened by a button with popovertarget="account-menu". Clicking outside, pressing Esc or opening another auto popover closes it. Because the menu is in the top layer it sits above the sticky header and anything with a high z-index.

The only positioning work is undoing the default centring: inset: auto, then top and right values that place it under the button. If you want it to follow the button wherever it is, use anchor positioning (demo 5).

Chromium exposes the button as expanded or collapsed automatically (we checked Chrome 151's accessibility tree), so we don't add aria-expanded, which would need script to stay in sync. A list of links doesn't need role="menu"; that role promises arrow-key navigation you would then have to build.

In a browser without popover support the attribute is ignored, so the links render as a normal panel. The @supports not selector(:popover-open) block hides the now useless toggle button and shows the note.

Open the demo in a new tab
Show the code
HTML
<header class="app-bar">
  <a class="brand" href="#top">Northwind Docs</a>
  <button class="account-btn" popovertarget="account-menu">
    <span class="avatar" aria-hidden="true">IK</span> Account
  </button>
</header>

<nav id="account-menu" class="account-menu" popover aria-label="Account">
  <p class="who"><strong>Ilze Kalnina</strong> [email protected]</p>
  <ul>
    <li><a href="#profile">Profile and avatar</a></li>
    <li><a href="#billing">Billing and invoices</a></li>
    <li><a href="#api-keys">API keys</a></li>
    <li><a href="#sign-out">Sign out</a></li>
    <li class="close-item"><button popovertarget="account-menu" popovertargetaction="hide">Close menu</button></li>
  </ul>
</nav>
CSS
.account-btn {
  display: inline-flex; align-items: center; gap: 8px;
  font: inherit; font-weight: 600; color: var(--ink);
  background: #fff; border: 1px solid var(--line); border-radius: 999px;
  padding: 4px 12px 4px 4px; cursor: pointer;
}
.account-btn:hover { border-color: #a9b2c1; }
.avatar { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 13px; }

/* The menu: popover gives us show/hide, top layer, Esc and light dismiss. */
.account-menu {
  width: min(17rem, calc(100vw - 32px));
  padding: 8px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: #fff;
  color: var(--ink);
  box-shadow: 0 12px 32px rgb(20 30 50 / .16);
}
@supports selector(:popover-open) {
  .account-menu {
    inset: auto;          /* undo the UA's centred inset: 0 */
    top: 58px; right: 16px;
    margin: 0;
  }
}
.account-menu .who { margin: 4px 8px 8px; font-size: 14px; color: var(--muted); }
.account-menu .who strong { display: block; color: var(--ink); font-size: 15px; }
.account-menu ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); padding-top: 6px; }
.account-menu a, .account-menu button {
  display: block; width: 100%; padding: 8px 10px; border-radius: 8px;
  color: var(--ink); text-decoration: none; font: inherit; text-align: start;
  background: none; border: 0; cursor: pointer;
}
.account-menu a:hover, .account-menu button:hover { background: #eef2f9; }

/* No popover support: the menu renders as an inline panel and the trigger is hidden. */
@supports not selector(:popover-open) {
  .account-btn { display: none; }
  .account-menu { margin: 12px 16px 0; box-shadow: none; }
  .account-menu .close-item { display: none; }
}

2. Stacked toast notifications with popover="manual"

Toasts must not vanish when the user clicks elsewhere, and several can be visible at once, which is exactly what manual popovers do. Each toast is cloned from a template, given popover="manual" and opened with showPopover().

Every popover is its own top-layer box, so they would overlap. The script recalculates a --y custom property for each open toast after every change, and the CSS uses it for inset-block-end. The toggle event (newState === "closed") removes the element and re-flows the stack whichever way the toast was closed.

Screen readers don't announce content just because it appears in the top layer. One persistent role="status" region outside the toasts receives the text of each new message. Success and info toasts close after six seconds, paused while hovered or focused; errors stay until dismissed.

Without the Popover API the same elements are simply position: fixed boxes appended to the body, so the demo still works, only without the top layer's guarantee of sitting above everything.

Open the demo in a new tab
Show the code
HTML
<div class="actions">
  <button class="btn" type="button" data-toast="success" data-title="Draft saved" data-text="Q3 pricing update, version 7.">Save draft</button>
  <button class="btn" type="button" data-toast="info" data-title="Uploading 3 files" data-text="invoices-2026-09.zip to /finance.">Upload files</button>
  <button class="btn" type="button" data-toast="error" data-title="Deploy failed" data-text="Build 1482 timed out after 10 minutes.">Simulate error</button>
</div>

<!-- Screen readers hear new toasts through one persistent live region. -->
<div class="sr-only" role="status" id="toast-live"></div>

<template id="toast-template">
  <div class="toast" popover="manual">
    <div class="toast-body">
      <p><strong></strong><span></span></p>
      <button type="button" aria-label="Dismiss notification">×</button>
    </div>
  </div>
</template>
CSS
.toast {
  /* Undo the UA popover centring and pin to the bottom-right corner.
     --y is set by the script so toasts stack instead of overlapping. */
  position: fixed;
  inset: auto 16px var(--y, 16px) auto;
  margin: 0;
  width: min(22rem, calc(100vw - 32px));
  padding: 12px 12px 12px 14px;
  border: 1px solid var(--line);
  border-left: 5px solid var(--tone, #2a55c9);
  border-radius: 12px;
  background: #fff; color: var(--ink);
  box-shadow: 0 10px 28px rgb(20 30 50 / .16);
  z-index: 10; /* only matters in the no-popover fallback; top layer ignores it */
}
/* Never set display on the popover itself: it would override the UA's
   [popover]:not(:popover-open) { display: none } and show closed toasts. */
.toast-body { display: flex; align-items: flex-start; gap: 10px; }
.toast[data-tone="success"] { --tone: #1f7a4d; }
.toast[data-tone="error"]   { --tone: #b42318; }
.toast p { margin: 0; flex: 1; font-size: 15px; }
.toast p strong { display: block; }
.toast button {
  flex: none; width: 32px; height: 32px; border-radius: 8px;
  border: 1px solid var(--line); background: #fff; color: var(--ink);
  font: inherit; font-size: 18px; line-height: 1; cursor: pointer;
}

@media (prefers-reduced-motion: no-preference) {
  .toast { transition: inset-block-end .2s ease, opacity .2s ease, translate .2s ease; }
  @starting-style { .toast:popover-open { opacity: 0; translate: 0 12px; } }
}
JavaScript
(() => {
  const supportsPopover = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'popover');
  const template = document.getElementById('toast-template');
  const live = document.getElementById('toast-live');
  const open = [];   // newest last
  const GAP = 8;

  if (!supportsPopover) document.getElementById('pe-note').hidden = false;

  // Recalculate bottom offsets so each toast sits above the newer ones.
  const layout = () => {
    let y = 16;
    for (let i = open.length - 1; i >= 0; i--) {
      open[i].style.setProperty('--y', `${y}px`);
      y += open[i].offsetHeight + GAP;
    }
  };

  const remove = (toast) => {
    const i = open.indexOf(toast);
    if (i === -1) return;
    open.splice(i, 1);
    clearTimeout(toast.timer);
    toast.remove();
    layout();
  };

  const dismiss = (toast) => {
    if (supportsPopover && toast.matches(':popover-open')) toast.hidePopover(); // fires "toggle"
    else remove(toast);
  };

  const showToast = ({ tone, title, text }) => {
    const toast = template.content.firstElementChild.cloneNode(true);
    toast.dataset.tone = tone;
    toast.querySelector('strong').textContent = title;
    toast.querySelector('span').textContent = text;
    toast.querySelector('button').addEventListener('click', () => dismiss(toast));
    toast.addEventListener('toggle', (e) => { if (e.newState === 'closed') remove(toast); });

    document.body.append(toast);
    if (supportsPopover) toast.showPopover();
    open.push(toast);
    if (open.length > 4) dismiss(open[0]);
    layout();
    live.textContent = `${title}. ${text}`;

    // Errors stay until dismissed; others close after 6 s, paused on hover or focus.
    if (tone !== 'error') {
      const start = () => { toast.timer = setTimeout(() => dismiss(toast), 6000); };
      const stop = () => clearTimeout(toast.timer);
      toast.addEventListener('pointerenter', stop);
      toast.addEventListener('pointerleave', start);
      toast.addEventListener('focusin', stop);
      toast.addEventListener('focusout', start);
      start();
    }
  };

  document.querySelectorAll('[data-toast]').forEach((btn) => {
    btn.addEventListener('click', () => showToast({
      tone: btn.dataset.toast, title: btn.dataset.title, text: btn.dataset.text,
    }));
  });
})();

3. Nested popovers: a sub-menu with light dismiss

Auto popovers close each other, so how does a sub-menu stay open next to its parent? The browser treats a popover as nested when its invoker sits inside another open popover, or when the popover is a DOM descendant of it. Here the Share button lives in the first menu, so opening the share menu keeps the parent open.

Light dismiss then works one level at a time. Clicking inside the parent menu closes only the sub-menu, clicking outside closes both, and Esc closes the top-most one first. We confirmed this in Chromium, Firefox and WebKit.

Both menus use anchor positioning: the parent sits under the Actions button, and the sub-menu opens to the inline end of the Share item. position-try-fallbacks lists flip-inline and a custom @position-try rule that drops it below the item on narrow screens. Without anchor positioning the menus stay centred and the sub-menu is nudged with translate so both remain visible.

Open the demo in a new tab
Show the code
HTML
<div class="project">
  <div>
    <h1>Riga office move</h1>
    <p>Updated 2 hours ago by Marta</p>
  </div>
  <button class="more-btn" popovertarget="project-menu">Actions</button>
</div>

<div id="project-menu" class="menu" popover>
  <ul>
    <li><button type="button">Rename</button></li>
    <li><button type="button">Duplicate</button></li>
    <li><!-- invoker inside the parent popover = nested popover -->
      <button type="button" class="share-trigger" popovertarget="share-menu">Share <span aria-hidden="true">›</span></button>
    </li>
    <li><button type="button" class="danger">Archive</button></li>
  </ul>
</div>

<div id="share-menu" class="menu" popover>
  <p class="menu-label">Share</p>
  <ul>
    <li><button type="button">Copy link</button></li>
    <li><button type="button">Invite by email</button></li>
    <li><button type="button">Export as PDF</button></li>
  </ul>
</div>
CSS
.more-btn {
  font: inherit; font-weight: 600; color: var(--ink); background: #fff;
  border: 1px solid var(--line); border-radius: 10px; padding: 6px 12px; cursor: pointer;
  anchor-name: --project-actions;
}
.menu {
  width: 12rem; padding: 6px;
  border: 1px solid var(--line); border-radius: 12px;
  background: #fff; color: var(--ink);
  box-shadow: 0 12px 30px rgb(20 30 50 / .18);
}
.menu ul { list-style: none; margin: 0; padding: 0; }
.menu button {
  display: flex; justify-content: space-between; align-items: center; width: 100%;
  padding: 8px 10px; border: 0; border-radius: 8px; background: none;
  font: inherit; color: var(--ink); text-align: start; cursor: pointer;
}
.menu button:hover { background: #eef2f9; }
.menu .danger { color: #a3190f; }
.menu-label { margin: 2px 10px 4px; font-size: 13px; font-weight: 600; color: var(--muted); }
.share-trigger { anchor-name: --share-item; }

/* Anchor the menus where supported; otherwise popovers stay centred (UA default). */
@supports (anchor-name: --a) {
  #project-menu {
    position-anchor: --project-actions;
    inset: auto;
    position-area: block-end span-inline-start;
    margin: 6px 0 0;
    position-try-fallbacks: flip-block;
  }
  #share-menu {
    position-anchor: --share-item;
    inset: auto;
    position-area: inline-end span-block-end;
    margin: 0 0 0 4px;
    position-try-fallbacks: flip-inline, --below;
  }
  @position-try --below {
    position-area: block-end span-inline-start;
    margin: 4px 0 0;
  }
}
@supports not (anchor-name: --a) {
  #share-menu { translate: 24px 24px; } /* offset so both menus are visible */
}

/* No popover support: show both lists inline and drop the triggers. */
@supports not selector(:popover-open) {
  .more-btn, .share-trigger { display: none; }
  .menu { margin-top: 12px; box-shadow: none; }
}

4. Animating open and close with @starting-style

A popover jumps from display: none to display: block, and transitions don't run from display: none. @starting-style gives the browser a style to transition from on the first frame, so the panel fades and lifts in.

Closing is harder because display flips to none immediately. Adding display and overlay to the transition list with allow-discrete delays that flip until the fade finishes. The overlay property keeps the element in the top layer while it animates out. The ::backdrop gets the same treatment so the dimmed background fades too.

Support differs. Chrome and Edge 117+ run both directions. Firefox supports @starting-style and transition-behavior from 129 but doesn't yet transition display, so the panel closes instantly there. Neither Firefox nor Safari supports the overlay property. Browsers without these features open and close the panel with no animation, which is a fine fallback.

Everything is inside @media (prefers-reduced-motion: no-preference), and inside @supports selector(:popover-open), so a browser without popover support never sees the opacity: 0 starting state.

Open the demo in a new tab
Show the code
HTML
<button class="btn btn-primary open-btn" popovertarget="whats-new">See what's new</button>

<div id="whats-new" class="whats-new" popover role="dialog" aria-labelledby="whats-new-title">
  <h2 id="whats-new-title">New in version 4.2</h2>
  <ul>
    <li>Live tram positions refresh every 10 seconds.</li>
    <li>Save up to five home and work stops.</li>
    <li>Night bus routes now appear after 23:30.</li>
  </ul>
  <div class="row">
    <button class="btn close-btn" popovertarget="whats-new" popovertargetaction="hide">Close</button>
  </div>
</div>
CSS
.whats-new {
  width: min(26rem, calc(100vw - 32px));
  padding: 20px;
  border: 0; border-radius: 16px;
  background: #fff; color: var(--ink);
  box-shadow: 0 24px 60px rgb(15 23 42 / .28);
}
.whats-new h2 { margin: 0 0 8px; font-size: 18px; }
.whats-new ul { margin: 0 0 16px; padding-left: 20px; }
.whats-new li { margin-bottom: 6px; }
.whats-new .row { display: flex; justify-content: flex-end; gap: 8px; }
.whats-new::backdrop { background-color: rgb(15 23 42 / 0); }
.whats-new:popover-open::backdrop { background-color: rgb(15 23 42 / .45); }

/* Gate on popover support: without it the panel is always visible, so it must not start at opacity 0. */
@supports selector(:popover-open) {
@media (prefers-reduced-motion: no-preference) {
  .whats-new {
    /* closed (exit) state */
    opacity: 0;
    translate: 0 16px;
    scale: .97;
    transition:
      opacity .25s ease, translate .25s ease, scale .25s ease,
      overlay .25s ease allow-discrete,   /* keep it in the top layer while fading out */
      display .25s ease allow-discrete;   /* flip display: none at the end, not the start */
  }
  .whats-new:popover-open { opacity: 1; translate: 0 0; scale: 1; }

  /* entry state: the first style the browser sees when the popover opens */
  @starting-style {
    .whats-new:popover-open { opacity: 0; translate: 0 16px; scale: .97; }
  }

  .whats-new::backdrop {
    transition: background-color .25s ease, overlay .25s allow-discrete, display .25s allow-discrete;
  }
  @starting-style {
    .whats-new:popover-open::backdrop { background-color: rgb(15 23 42 / 0); }
  }
}
}

@supports not selector(:popover-open) {
  .open-btn, .whats-new .close-btn { display: none; }
  .whats-new { box-shadow: none; border: 1px solid var(--line); }
}

5. Popover plus CSS anchor positioning

The missing piece of the Popover API is placement: it puts a layer on top but doesn't know where your button is. CSS anchor positioning fills that gap. Each button gets an anchor-name, each card gets the matching position-anchor, and one rule places every card: position-area: block-end span-inline-end puts it below the button, extending to the right.

position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline tells the browser what to try when the card doesn't fit. Open a card on the bottom row and it appears above its button; near the right edge it mirrors to the left.

Popovers opened with popovertarget also get the invoker as an implicit anchor (Chrome and Edge 133+, Firefox 147+, Safari 26+), so you can skip anchor-name in newer browsers. We name the anchors explicitly here because it also works in Chrome 125 to 132.

Anchor positioning works in Chrome and Edge 125+, Firefox 147+ and Safari 26+, but web-features still marks it as limited availability because of differences in details. Inside @supports (anchor-name: --a) we reset inset; elsewhere the cards stay centred by the user-agent styles.

Open the demo in a new tab
Show the code
HTML
<ul class="rota">
  <li>
    <button class="person" popovertarget="p-anna" style="anchor-name: --anna">Anna Berzina <span>Mon–Wed, 08:00</span></button>
    <div class="card" id="p-anna" popover style="position-anchor: --anna">
      <h2>Anna Berzina</h2><p>Tier 2, billing. Covers the 08:00–16:00 shift and the first escalation queue.</p>
    </div>
  </li>
  <li>
    <button class="person" popovertarget="p-omar" style="anchor-name: --omar">Omar Haddad <span>Mon–Fri, 12:00</span></button>
    <div class="card" id="p-omar" popover style="position-anchor: --omar">
      <h2>Omar Haddad</h2><p>Tier 1, chat. Handles the lunchtime peak and onboarding calls.</p>
    </div>
  </li>
  <li>
    <button class="person" popovertarget="p-li" style="anchor-name: --li">Li Wei <span>Tue–Sat, 16:00</span></button>
    <div class="card" id="p-li" popover style="position-anchor: --li">
      <h2>Li Wei</h2><p>Tier 2, API. On call for webhook failures until midnight.</p>
    </div>
  </li>
  <li>
    <button class="person" popovertarget="p-sara" style="anchor-name: --sara">Sara Lind <span>Thu–Sun, 08:00</span></button>
    <div class="card" id="p-sara" popover style="position-anchor: --sara">
      <h2>Sara Lind</h2><p>Team lead. Approves refunds above 500 EUR and owns the weekend handover.</p>
    </div>
  </li>
  <li>
    <button class="person" popovertarget="p-jonas" style="anchor-name: --jonas">Jonas Petraitis <span>Sat–Sun, 10:00</span></button>
    <div class="card" id="p-jonas" popover style="position-anchor: --jonas">
      <h2>Jonas Petraitis</h2><p>Tier 1, email. Clears the weekend backlog before Monday 09:00.</p>
    </div>
  </li>
  <li>
    <button class="person" popovertarget="p-mia" style="anchor-name: --mia">Mia Torres <span>Mon–Fri, 20:00</span></button>
    <div class="card" id="p-mia" popover style="position-anchor: --mia">
      <h2>Mia Torres</h2><p>Night shift, all tiers. Pages the on-call engineer for outages.</p>
    </div>
  </li>
</ul>
CSS
.rota { list-style: none; margin: auto 0 0; padding: 0; flex: 1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; align-content: space-between; }
@media (max-width: 480px) { .rota { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.person {
  width: 100%; display: grid; gap: 2px; justify-items: start; text-align: start;
  padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px;
  background: #fff; color: var(--ink); font: inherit; cursor: pointer;
}
.person:hover { border-color: #a9b2c1; }
.person span { font-size: 13px; color: var(--muted); }

.card {
  width: 15rem; max-width: calc(100vw - 24px);
  padding: 12px 14px;
  border: 1px solid var(--line); border-radius: 12px;
  background: #fff; color: var(--ink);
  box-shadow: 0 12px 30px rgb(20 30 50 / .18);
}
.card h2 { margin: 0 0 4px; font-size: 16px; }
.card p { margin: 0; font-size: 14px; color: var(--muted); }

@supports (anchor-name: --a) {
  .card {
    inset: auto;                                   /* drop the UA centring */
    position-area: block-end span-inline-end;      /* below, growing to the right */
    margin: 6px 0;
    /* If it doesn't fit, try above, then mirrored, then both. */
    position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
  }
}

@supports not selector(:popover-open) {
  .card { width: auto; margin-top: 6px; box-shadow: none; }
}

6. Popover vs dialog: modal and non-modal

Use a modal dialog when the user must answer before doing anything else: confirming a destructive action, signing in, an unsaved-changes prompt. showModal() puts the dialog in the top layer, makes the rest of the page inert, moves focus inside (here to Cancel via autofocus) and closes on Esc. Until it closes, focus can't move to the rest of the page.

Use a popover for anything the user can ignore or work around: menus, pickers, settings panels, toasts. The page stays interactive, focus stays on the button that opened it, and the popover's contents come next in the Tab order. Click the counter while the settings popover is open: the popover light-dismisses and the click still lands.

Both buttons use invoker commands: command="show-modal" with commandfor for the dialog and command="toggle-popover" for the panel. Commands are Baseline 2025 (Chrome and Edge 135, Firefox 144, Safari 26.2). For older browsers a few lines of script read the same attributes and call showModal() or togglePopover().

A popover can also be a dialog element (non-modal) or carry role="dialog", as the settings panel does, when it holds form controls and a heading. If you want light dismiss on a modal, dialog closedby="any" adds it in Chrome and Edge 134+ and Firefox 141+, but Safari hasn't shipped it yet.

Open the demo in a new tab
Show the code
HTML
<div class="cols">
  <section class="col" aria-labelledby="modal-h">
    <h2 id="modal-h">Modal <dialog></h2>
    <p>Blocks the page until you answer. Focus moves in and the rest of the page is inert.</p>
    <button class="btn btn-danger" commandfor="confirm-delete" command="show-modal">Delete workspace…</button>
    <p class="status" id="dialog-result" role="status"></p>
  </section>

  <section class="col" aria-labelledby="popover-h">
    <h2 id="popover-h">Non-modal popover</h2>
    <p>Floats above the page. You can keep working; clicking outside closes it.</p>
    <button class="btn panel-btn" commandfor="notify-panel" command="toggle-popover">Notification settings</button>
  </section>
</div>

<dialog id="confirm-delete" class="confirm" aria-labelledby="confirm-title">
  <form method="dialog">
    <h3 id="confirm-title">Delete the Riga office workspace?</h3>
    <p>This removes 14 boards and 312 cards for everyone. It can't be undone.</p>
    <div class="row">
      <button class="btn" value="cancel" autofocus>Cancel</button>
      <button class="btn btn-danger" value="delete">Delete workspace</button>
    </div>
  </form>
</dialog>

<div id="notify-panel" class="panel" popover role="dialog" aria-labelledby="notify-title">
  <h3 id="notify-title">Notify me about</h3>
  <label><input type="checkbox" checked> Mentions</label>
  <label><input type="checkbox" checked> Cards assigned to me</label>
  <label><input type="checkbox"> Every new comment</label>
</div>
CSS
/* Modal: showModal() puts the dialog in the top layer and makes the page inert. */
.confirm {
  width: min(24rem, calc(100vw - 32px));
  padding: 20px; border: 0; border-radius: 16px;
  background: #fff; color: var(--ink);
  box-shadow: 0 24px 60px rgb(15 23 42 / .3);
}
.confirm::backdrop { background: rgb(15 23 42 / .5); }
.confirm h3 { margin: 0 0 8px; font-size: 18px; }
.confirm p { margin: 0 0 16px; color: var(--muted); }
.confirm .row { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }

/* Non-modal: the popover floats in the top layer, the page stays usable. */
.panel {
  width: min(18rem, calc(100vw - 32px));
  padding: 14px 16px;
  border: 1px solid var(--line); border-radius: 14px;
  background: #fff; color: var(--ink);
  box-shadow: 0 12px 30px rgb(20 30 50 / .18);
}
.panel h3 { margin: 0 0 8px; font-size: 16px; }
.panel label { display: flex; gap: 8px; align-items: center; padding: 4px 0; }
.panel input { width: 18px; height: 18px; accent-color: var(--accent); }
.panel-btn { anchor-name: --notify; }
@supports (anchor-name: --a) {
  .panel { position-anchor: --notify; inset: auto; position-area: block-end span-inline-end; margin: 6px 0; position-try-fallbacks: flip-block, flip-inline; }
}
@supports not selector(:popover-open) {
  .panel-btn { display: none; }
  .panel { box-shadow: none; }
}
JavaScript
(() => {
  const supportsCommands = 'command' in HTMLButtonElement.prototype;
  const supportsPopover = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'popover');

  // Fallback for browsers without invoker commands: wire the same attributes up by hand.
  if (!supportsCommands) {
    document.getElementById('note-command').hidden = false;
    document.querySelectorAll('button[commandfor]').forEach((btn) => {
      btn.addEventListener('click', () => {
        const target = document.getElementById(btn.getAttribute('commandfor'));
        const command = btn.getAttribute('command');
        if (command === 'show-modal') target.showModal();
        if (command === 'close') target.close();
        if (command === 'toggle-popover' && supportsPopover) target.togglePopover();
      });
    });
  }
  if (!supportsPopover) document.getElementById('note-popover').hidden = false;

  const dialog = document.getElementById('confirm-delete');
  const result = document.getElementById('dialog-result');
  dialog.addEventListener('close', () => {
    result.textContent = dialog.returnValue === 'delete' ? 'Workspace deleted (demo only).' : 'Cancelled. Nothing was deleted.';
  });
})();

7. Tooltips with popover="hint"

Tooltips have a conflict with auto popovers: if hovering a toolbar button closed the open menu, the toolbar would be unusable. popover="hint" fixes that. A hint closes other hints, responds to Esc and outside clicks, but doesn't close auto popovers. Open More, then hover Bold: both stay open.

Hints are shown by script on pointerenter (after a short delay) and on keyboard focus, and hidden on pointerleave, blur and click. The tooltip stays open while the pointer is over it, so it is hoverable as WCAG 1.4.13 requires. Each icon button uses aria-labelledby pointing at its tooltip, so the tooltip text is also the button's accessible name.

Support is the newest of anything here: Chrome and Edge 151 and Firefox 153 implement the current spec (earlier versions shipped an older, inconsistent version), and Safari doesn't support it yet. The script detects support by setting popover="hint" on a test element and reading it back. Safari reads manual, so we switch the tooltips to manual popovers and handle Esc ourselves. With no popover support at all they fall back to the hidden attribute and fixed positioning.

Chrome and Edge 142+ also support interest invokers (the interestfor attribute), which show a popover on hover or focus without any script. Firefox and Safari haven't shipped them, so treat interestfor as an enhancement on top of the script for now.

Open the demo in a new tab
Show the code
HTML
<div class="editor">
  <div class="toolbar" role="toolbar" aria-label="Formatting">
    <button class="tool" type="button" data-tip="tip-bold" aria-labelledby="tip-bold" style="anchor-name: --bold">B</button>
    <button class="tool" type="button" data-tip="tip-italic" aria-labelledby="tip-italic" style="anchor-name: --italic"><i>I</i></button>
    <button class="tool" type="button" data-tip="tip-link" aria-labelledby="tip-link" style="anchor-name: --link">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
    </button>
    <button class="tool" type="button" data-tip="tip-undo" aria-labelledby="tip-undo" style="anchor-name: --undo">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-3"/></svg>
    </button>
    <button class="tool more" type="button" popovertarget="more-menu">More</button>
  </div>
  <p class="editor-text">Quarterly report draft: revenue grew 12 percent, led by the Baltic region.</p>
</div>

<div class="tip" id="tip-bold" popover="hint" role="tooltip" style="position-anchor: --bold">Bold (Ctrl+B)</div>
<div class="tip" id="tip-italic" popover="hint" role="tooltip" style="position-anchor: --italic">Italic (Ctrl+I)</div>
<div class="tip" id="tip-link" popover="hint" role="tooltip" style="position-anchor: --link">Insert link (Ctrl+K)</div>
<div class="tip" id="tip-undo" popover="hint" role="tooltip" style="position-anchor: --undo">Undo (Ctrl+Z)</div>

<div class="menu" id="more-menu" popover>
  <button type="button">Insert table</button>
  <button type="button">Add comment</button>
  <button type="button">Word count</button>
</div>
CSS
.toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; border-bottom: 1px solid var(--line); background: #f8f9fc; }
.tool {
  display: grid; place-items: center; width: 40px; height: 40px;
  border: 1px solid transparent; border-radius: 8px; background: none;
  color: var(--ink); font: inherit; font-weight: 700; cursor: pointer;
}
.tool:hover { background: #e8ecf3; border-color: var(--line); }
.tool svg { width: 20px; height: 20px; }
.more { margin-inline-start: auto; width: auto; padding: 0 12px; font-weight: 600; anchor-name: --more; }

.tip {
  padding: 4px 8px; border: 0; border-radius: 6px;
  background: #1b2230; color: #fff; font-size: 13px; line-height: 1.4;
  white-space: nowrap; pointer-events: auto;
}
.tip[hidden] { display: none; } /* no-popover fallback */
@supports (anchor-name: --a) and selector(:popover-open) {
  .tip { inset: auto; position-area: block-start span-inline-end; margin: 0 0 6px; position-try-fallbacks: flip-block; }
}

.menu {
  width: 12rem; padding: 6px;
  border: 1px solid var(--line); border-radius: 12px; background: #fff; color: var(--ink);
  box-shadow: 0 12px 30px rgb(20 30 50 / .18);
}
.menu button { display: block; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: none; font: inherit; color: var(--ink); text-align: start; cursor: pointer; }
.menu button:hover { background: #eef2f9; }
@supports (anchor-name: --a) {
  .menu { position-anchor: --more; inset: auto; position-area: block-end span-inline-start; margin: 6px 0 0; }
}
@supports not selector(:popover-open) { .more { display: none; } .menu { margin: 8px; box-shadow: none; } }
JavaScript
(() => {
  const supportsPopover = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'popover');
  const probe = document.createElement('div');
  probe.setAttribute('popover', 'hint');
  const supportsHint = supportsPopover && probe.popover === 'hint'; // unknown values fall back to "manual"
  const supportsAnchor = CSS.supports('anchor-name: --a');
  const note = document.getElementById('note');

  if (!supportsPopover) note.textContent = "Your browser doesn't support the Popover API — tooltips use the hidden attribute and fixed positioning instead.";
  else if (!supportsHint) note.textContent = "Your browser doesn't support popover=\"hint\" — tooltips fall back to popover=\"manual\", so they behave the same but don't use the hint stack.";
  else if (!supportsAnchor) note.textContent = "Your browser doesn't support CSS anchor positioning — tooltips are placed with a few lines of JavaScript instead.";
  note.hidden = supportsHint && supportsAnchor;

  let current = null;
  const isOpen = (tip) => (supportsPopover ? tip.matches(':popover-open') : !tip.hidden);

  const place = (tip, btn) => {           // only when anchor positioning (or popover) is missing
    if (supportsAnchor && supportsPopover) return;
    const r = btn.getBoundingClientRect();
    Object.assign(tip.style, { position: 'fixed', inset: 'auto', margin: '0', left: `${r.left}px`, top: `${r.top - tip.offsetHeight - 6}px` });
  };
  const show = (tip, btn) => {
    if (current && current !== tip) hide(current);
    current = tip;
    if (isOpen(tip)) return;
    if (supportsPopover) tip.showPopover(); else tip.hidden = false;
    place(tip, btn);
  };
  const hide = (tip) => {
    clearTimeout(tip.timer);
    if (!isOpen(tip)) return;
    if (supportsPopover) tip.hidePopover(); else tip.hidden = true;
    if (current === tip) current = null;
  };

  document.querySelectorAll('[data-tip]').forEach((btn) => {
    const tip = document.getElementById(btn.dataset.tip);
    if (!supportsPopover) tip.hidden = true;
    else if (!supportsHint) tip.popover = 'manual';

    const hideSoon = () => { clearTimeout(tip.timer); tip.timer = setTimeout(() => hide(tip), 150); };
    btn.addEventListener('pointerenter', () => { clearTimeout(tip.timer); tip.timer = setTimeout(() => show(tip, btn), 300); });
    btn.addEventListener('pointerleave', hideSoon);
    tip.addEventListener('pointerenter', () => clearTimeout(tip.timer)); // hoverable (WCAG 1.4.13)
    tip.addEventListener('pointerleave', hideSoon);
    btn.addEventListener('focus', () => { if (btn.matches(':focus-visible')) show(tip, btn); });
    btn.addEventListener('blur', () => hide(tip));
    btn.addEventListener('click', () => hide(tip));
  });

  // Esc dismisses the tooltip (hint popovers do this natively; the fallbacks need it).
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && current && !supportsHint) {
      e.preventDefault(); // close only the tooltip, not an open menu behind it
      hide(current);
    }
  });
})();

8. A mobile bottom sheet built with popover

A bottom sheet is an auto popover pinned to the bottom edge: inset: auto 0 0 0, width: 100% with a max-width, and margin: 0 auto to centre it on wide screens. max-height: 85dvh with overflow: auto keeps long content scrollable, and env(safe-area-inset-bottom) clears the home indicator on phones.

It slides up with the same @starting-style pattern as demo 4 (translate: 0 100% to 0), and the ::backdrop dims the trip behind it. Tapping the backdrop or pressing Esc closes it; the close button and Confirm ride use popovertargetaction="hide".

The sheet holds a form, so it carries role="dialog" and aria-labelledby pointing at its heading. A small change listener copies the chosen ride into the trip summary. It runs whether or not the browser supports popovers, because without support the sheet is just a card below the trip.

Swipe-to-dismiss isn't built in. If you need it, add a pointer handler that calls hidePopover() past a drag threshold, and keep the close button for users who can't swipe.

Open the demo in a new tab
Show the code
HTML
<section class="trip" aria-labelledby="trip-title">
  <h1 id="trip-title">Airport to Old Town</h1>
  <p>11.4 km, about 18 minutes in current traffic</p>
  <dl>
    <dt>Ride</dt><dd id="ride-choice">Economy, 8.40 EUR</dd>
    <dt>Pickup</dt><dd>Terminal B, door 3</dd>
  </dl>
  <button class="btn btn-primary open-sheet" popovertarget="ride-sheet">Change ride</button>
</section>

<div id="ride-sheet" class="sheet" popover role="dialog" aria-labelledby="sheet-title">
  <span class="grip" aria-hidden="true"></span>
  <div class="sheet-head">
    <h2 id="sheet-title">Choose a ride</h2>
    <button class="close-x" popovertarget="ride-sheet" popovertargetaction="hide" aria-label="Close">×</button>
  </div>
  <fieldset>
    <legend class="sr-only">Ride type</legend>
    <label class="ride"><input type="radio" name="ride" value="Economy, 8.40 EUR" checked><span>Economy <small>4 seats, 3 min away</small></span>8.40 EUR</label>
    <label class="ride"><input type="radio" name="ride" value="Comfort, 11.90 EUR"><span>Comfort <small>Newer cars, 5 min away</small></span>11.90 EUR</label>
    <label class="ride"><input type="radio" name="ride" value="Van, 16.20 EUR"><span>Van <small>6 seats and luggage, 9 min away</small></span>16.20 EUR</label>
  </fieldset>
  <button class="btn btn-primary" popovertarget="ride-sheet" popovertargetaction="hide">Confirm ride</button>
</div>
CSS
.sheet {
  /* Pin to the bottom edge instead of the UA's centred box. */
  inset: auto 0 0 0;
  width: 100%; max-width: 32rem;
  max-height: 85dvh; overflow: auto;
  margin: 0 auto;
  padding: 8px 16px calc(16px + env(safe-area-inset-bottom));
  border: 0; border-radius: 20px 20px 0 0;
  background: #fff; color: var(--ink);
  box-shadow: 0 -12px 40px rgb(15 23 42 / .2);
}
.sheet::backdrop { background: rgb(15 23 42 / .45); }
.sheet .grip { display: block; width: 40px; height: 4px; margin: 4px auto 12px; border-radius: 4px; background: #c3c9d4; }
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.sheet h2 { margin: 0; font-size: 18px; }
.sheet fieldset { border: 0; margin: 0 0 14px; padding: 0; display: grid; gap: 8px; }
.ride { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; }
.ride:has(input:checked) { border-color: var(--accent); background: #eef3ff; }
.ride input { width: 18px; height: 18px; accent-color: var(--accent); }
.ride span { flex: 1; }
.ride small { display: block; color: var(--muted); font-size: 13px; }
.close-x { width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--line); background: #fff; color: var(--ink); font: inherit; font-size: 20px; line-height: 1; cursor: pointer; }

@supports selector(:popover-open) {
  @media (prefers-reduced-motion: no-preference) {
    .sheet {
      translate: 0 100%;
      transition: translate .3s cubic-bezier(.2, .8, .2, 1), overlay .3s allow-discrete, display .3s allow-discrete;
    }
    .sheet:popover-open { translate: 0 0; }
    @starting-style { .sheet:popover-open { translate: 0 100%; } }
  }
}

/* Without popover support the sheet is an ordinary card under the trip summary. */
@supports not selector(:popover-open) {
  .open-sheet, .close-x, .sheet .grip { display: none; }
  .sheet { margin-top: 12px; border-radius: 16px; border: 1px solid var(--line); box-shadow: none; }
}
JavaScript
// Reflect the chosen ride in the trip summary (works with or without popover support).
const sheet = document.getElementById('ride-sheet');
const choice = document.getElementById('ride-choice');
sheet.addEventListener('change', (e) => { if (e.target.name === 'ride') choice.textContent = e.target.value; });

Accessibility: what popover gives you and what it doesn't

You get, for free: top-layer rendering, so nothing covers the popover; Esc and light dismiss for auto and hint popovers; focus returning to the previously focused element when a popover closes with focus inside it; the popover's contents following its invoker in the Tab order; and, in Chromium at least, an expanded or collapsed state exposed on popovertarget buttons. We checked the focus behaviour in Chromium, Firefox and WebKit.

You don't get roles or focus management. A popover is a generic element: add role="dialog" plus a label for panels with controls, role="tooltip" for tooltips, and use real lists, links and buttons for menus. Focus isn't moved into a popover unless something inside has autofocus, and focus isn't trapped. That is right for non-modal UI, but use dialog with showModal() when you need a modal.

Content that appears dynamically, such as toasts, isn't announced. Use a persistent live region. Keep keyboard parity for anything hover-driven (show tooltips on focus too), keep text contrast at 4.5:1 inside popovers, and give interactive elements a visible :focus-visible style. Every demo here passes axe-core's WCAG 2.1 AA rules in its open state.

Fallbacks and progressive enhancement

In a browser without the Popover API the popover attribute is ignored and the element renders in place. Use that: style popover content so it reads well inline, detect support with @supports selector(:popover-open) in CSS or Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'popover') in JavaScript, and hide triggers that would do nothing.

Layer the newer features separately. Gate anchor positioning with @supports (anchor-name: --a) and let popovers fall back to the default centred position. Gate exit animations on transition-behavior support and wrap all motion in prefers-reduced-motion. Detect popover="hint" by reading the property back, and detect invoker commands with 'command' in HTMLButtonElement.prototype.

Each demo shows a small note when it is running a fallback. We tested each one in Chromium with the feature removed (attributes stripped, @supports conditions forced false and APIs deleted), and in Firefox and WebKit, where hint and overlay support differ.

Frequently asked questions

What is the difference between popover and dialog?

A modal dialog opened with showModal() blocks the rest of the page, moves focus inside and keeps it there until it closes. A popover is non-modal: the page stays usable, focus stays where it was, and auto popovers close when you click outside. Use a modal dialog for decisions the user must make, and a popover for menus, tooltips, toasts and panels.

Do I need JavaScript to use the popover attribute?

No. A button with popovertarget pointing at an element with the popover attribute opens and closes it, and the browser handles Esc and outside clicks. You need script only for things like timed toasts, hover-triggered tooltips or reacting to the toggle event.

What is the difference between popover="auto", "manual" and "hint"?

Auto popovers light-dismiss and close other auto popovers unless they are nested. Manual popovers only close when you tell them to, and several can be open together. Hint popovers are for tooltips: they close other hints but leave auto popovers open. Safari doesn't support hint yet and treats it as manual.

How do I position a popover next to its button?

Use CSS anchor positioning: give the button an anchor-name, give the popover the matching position-anchor, reset inset to auto and set position-area. position-try-fallbacks lets it flip when it doesn't fit. Without anchor positioning you can set top and left from getBoundingClientRect() in JavaScript.

Why is my popover always visible?

Usually because a display value on the popover element overrides the user-agent rule that hides closed popovers. Put display: flex or grid on an inner wrapper, or apply it only in a :popover-open rule. In a browser without popover support the attribute is ignored, so the element shows inline; detect that with @supports selector(:popover-open).

More from the Modern CSS lab