<ui-popover>ui-popover.mjs · Full test suite
| Element | Attributes | Properties | Methods | Events |
|---|---|---|---|---|
<ui-popover> |
placement, label |
.placement, .label, .open |
.close() |
ui-popover-toggle (detail.open) |
.open is read-only and reports whether the panel is showing. .close()
dismisses it and is safe to call when it is already closed. There is no .open()
method: the trigger button carries popovertarget, so opening is the UA's job.
ui-popover-toggle fires on the <ui-popover> element itself and
does not bubble — listen on the element, never on an ancestor.
popover="auto") for light-dismiss and
top-layer rendering — no custom focus trap needed.role="dialog" and is named via
aria-labelledby pointing at the trigger button. The button's
visible text (or its aria-label for icon-only triggers) becomes
the dialog's accessible name.label attribute on the <ui-popover> puts that
text on the panel as aria-label, which names the dialog directly and
wins over the trigger. Use it when the button's own wording is a poor title for
the panel.aria-haspopup="dialog",
aria-expanded, and aria-controls.[tabindex] element in the panel. If none exists, the panel receives tabindex="-1" and focus itself. A plain <a href> is not included in that search. Closing returns focus to the trigger. Escape closes this panel unless an inner widget already handled the event; see the details below.
aria-label on the
<button> — that label becomes both the button's and the
dialog's accessible name.
The panel's keydown listener handles Escape with both stopPropagation() and preventDefault(). The first blocks ancestor listeners; the second prevents the browser from closing another overlay after this panel closes. Both calls are required.
If defaultPrevented is already true, the panel leaves the event alone. An inner picker, menu, or combobox has already handled it and should close without closing the panel too. The next Escape press can then close the panel. Nested popovers stop propagation themselves, so their handled events do not reach this listener.
stopPropagation() prevents an ancestor panel from handling the same Escape event and closing too when nesting popovers. If an inner widget has already cancelled the event, this panel leaves it alone, allowing it to continue bubbling with defaultPrevented set. An inner popover stops its own handled events, so those do not reach the outer listener.
preventDefault() prevents a second close by the browser. Because the panel uses popover="auto", it participates in the browser's close-watcher stack. The browser processes Escape's close request after event dispatch, when the listener has already removed this panel from the stack. Without cancellation, the request would close the next overlay, such as the containing dialog. stopPropagation() alone cannot prevent that.
Capture listeners run before the panel's handler and therefore see defaultPrevented as false. At that point, check document.querySelector(':popover-open') to detect the still-open panel; cancellation has not happened yet.
Menus cancel Escape but allow it to bubble after closing. Popovers cancel and stop events they handle; events already handled by an inner widget can still bubble through them. Ancestor Escape handlers should therefore check defaultPrevented first. See Accessibility for the differences between overlay components.
Test with real key presses in a browser. A synthetic KeyboardEvent does not create a browser close request, and a <dialog open> stand-in has no close watcher. Neither can reveal an unintended second close.
Simple popover with text content. The first direct-child <button> becomes the trigger; everything else is moved into the panel.
This is popover content with simple text.
Settings popover with form controls — icon button as trigger.
Popover with keyboard shortcuts — icon button as trigger, definition list as content.
Eight placement options via the placement attribute — four on the block axis
(above and below the trigger) and four on the inline axis (beside it). Default is
bottom-end. The resting gap that separates panel from trigger sits on whichever
edge faces the trigger, so it follows the placement rather than always being a top margin.
Placed bottom-end.
Placed bottom-start.
Placed top-end.
Placed top-start.
Placed right-start.
Placed left-start.
Placed right-end.
Placed left-end.
Side placements use position-try-fallbacks: flip-inline to move to the other side when needed near a viewport edge. The four block-axis placements do not flip. §7 explains their available-space sizing, which automatic flipping would obscure.
A <ui-popover> may sit inside another one's content and open as a flyout off
a row of it — the account menu's Advanced section is built this way. Nothing has to be
wired for it: the panel is appended to its own host element, so a nested instance is a DOM
descendant of the outer panel, and the Popover API computes its light-dismiss ancestry from that
containment. Opening the inner panel therefore leaves the outer one showing, and closing the
outer one takes the inner with it.
Use a side placement so the nested panel opens beside its parent. Escape should close one level and return focus to that level's trigger. Follow the Escape-handling rules: stopping propagation protects the parent listener, while cancellation prevents the browser's close watcher from closing the parent after the child.
An ordinary row.
Inside the flyout.
A <ui-menu> is not the way to do this when the flyout holds anything
but action rows. That component is APG role="menu", which forbids static
text and embedded widgets — the same reason an account menu is a popover in the first place.
Reach for <ui-menu>'s own submenus only for pure action lists.
Two popovers next to each other — opening one automatically closes the other (popover="auto").
Content in popover A.
Content in popover B.
The panel uses its content's preferred width, capped at 45 ch (--size-content-2), with longer text wrapping. Both width declarations are necessary. The browser's default fit-content depends on the space between the inset and viewport edge, which can make a panel unexpectedly narrow. Without the explicit width, the 41-character paragraph in §1 measured 337×90 mid-page but 96×210 near the edge in both engines. Conversely, max-content without a cap would keep a long paragraph on one line. Check the width and cap together.
This block-placement behavior is visible in both engines. Unlike <ui-menu> and tooltip(), it has no position-try-fallbacks that could move the panel to a roomier side.
Declare a width of your own when the panel should be a fixed size whatever it holds — any
width or min-inline-size on your own selector outranks the component's.
The studio's bookmark form takes width: 16rem, the account menu
min-inline-size: 15rem.
Two takes are still recording.
Two takes are still recording, and the article they belong to cannot be marked ready until every one of them has finished and been reviewed.