User Experience (UX)
Now let's look at how a popover behaves for every person, input and setting. A good popover is accessible, predictable, designed in every state and adaptive.
Accessible
Use the popover attribute
The popover attribute gives you most of what a popover needs, in every browser since 2024 (Chrome and Edge 114, Firefox 125 and Safari 17). With popover="auto", the browser:
- Puts it in the top layer while it's open, above everything else, without a
z-index. - Opens and closes it from any button that points at it with
popovertarget, before any script has loaded, and marks that button as expanded or collapsed for screen readers (HTML-AAM). - Closes it on
Escape, wherever focus is, and on a press outside it, which is called light dismiss, and gives focus back when it closes from the inside (HTML Standard). - Keeps one open at a time, so opening a popover closes any other, unless it opened from inside that one.
A scripted popover, a <div> your code shows, positions and moves to the end of the <body>, has none of these until you rebuild each one. Moving it away from its trigger also breaks the reading order, so screen reader users have to go looking for what just opened. popover="manual" is in between: it's in the top layer, but the browser never closes it, which is why the Tooltip uses it, since it opens and closes on hover and focus, under its own rules.
{/* Do: the top layer, light dismiss, Escape and a trigger that works before your script */}
<button type="button" popoverTarget="filters" aria-haspopup="dialog">Filters</button>
<div id="filters" popover="auto" role="dialog" aria-labelledby="filters-title">…</div>
{/* Don't: there's no top layer, light dismiss or Escape until you rebuild them */}
<button type="button" onClick={() => setOpen(!open)}>Filters</button>
{open && createPortal(<div className="popover" style={position}>…</div>, document.body)}
A <dialog> can take the popover attribute too, but then it has two ways of being open, its open attribute and :popover-open, which don't follow each other, so we use a <div> with role="dialog". Opening a <dialog> with show() instead leaves it out of the top layer, and nothing closes it on a press outside.
Name it, and say what it is
A popover has no role of its own, and the browser exposes one without a role as a group (HTML-AAM), which screen readers don't announce as something that just opened. So the Popover sets role="dialog", which is what WAI-ARIA calls a window on top of the page that people interact with, and leaves out aria-modal, since the page around it still works (WAI-ARIA 1.2). It's named by its title, which it links with aria-labelledby, so give every popover a title that says what it holds (WCAG 2.4.6, 4.1.2).
Its trigger says what it opens, and whether it's open, as the Button's button/popup-expanded rule asks. popovertarget gives it an expanded state, but only in the accessibility tree, and no popup type (HTML-AAM). So add aria-haspopup="dialog" to the trigger, and the Popover keeps aria-expanded on it in step with whether it's open, which is also what the Button's Expanded state and agents read.
Here's what each screen reader said on a button with aria-haspopup="dialog", adapted from Manuel Matuzović's tests of a Settings button:
| Screen reader | The trigger |
|---|---|
| NVDA, in Chrome and Firefox | "Settings, button, opens dialog" |
| JAWS | "Settings, button has pop-up dialogue" |
| VoiceOver on macOS | "Settings, dialogue pop-up, button" |
| VoiceOver on iOS | "Settings, pop-up button, dialogue pop-up" |
| TalkBack | "Settings, dialogue pop-up button" |
| Narrator, in Edge | "Settings, button, has pop-up" |
He also found that VoiceOver in Safari doesn't announce aria-expanded on a button with aria-haspopup. When the popover opens, focus moves into a dialog named by its title, which Chrome's accessibility tree shows as dialog "Filters".
Don't use role="menu" for a popover. A menu holds only menu items, which people move between with the arrow keys, and screen readers switch into a mode for it, so a form or a link inside one stops working the way people expect. That's the Dropdown Menu's job.
Move focus in, and back out
When a popover opens, focus moves inside it, so keyboard and screen reader users start right where they asked to go (WCAG 2.4.3). The browser only moves it to a control marked with autofocus, so the Popover adds the Dialog's order. It moves focus to:
- The control you mark with
data-autofocus, or withautofocus. - Or else, the first control in its body, like the first checkbox of a set of filters.
- Or else, its close button, for a popover that holds only text.
When it closes with Escape, its close button or anything else that happens while focus is inside it, focus goes back to its trigger, so people carry on where they left off. The browser does most of this itself, but it gives focus back to whatever had it before the popover opened, and Safari doesn't focus a button when you click it, so the Popover hands it to the trigger. A press outside is different: focus stays where people pressed, since that's where they've moved on to (HTML Standard).
Focus isn't trapped inside, since the page around a popover still works (WCAG 2.1.2). Tab from its last control moves on to what comes after its trigger, and the popover stays open, as it does in every browser. It may then cover the control that has focus, which WCAG allows as long as people can put it away without moving focus, and Escape does that from anywhere on the page (WCAG 2.4.11).
Contrast and size
- The title and the content need a contrast of at least 4.5:1 against the panel (WCAG 1.4.3).
- The panel's border doesn't need 3:1, since the panel isn't a control, and its title, its shadow and its place beside the trigger already set it apart.
- The close button is an
xsButton, 24 × 24px, the minimum (WCAG 2.5.8), so it stays small beside the title.
Predictable
Open it on purpose
A popover opens from a press on its trigger, and never on hover or on focus alone. Opening on hover would cover the page as the pointer crosses it, and opening on focus would move focus into the popover as people tab past its trigger, pulling them away from where they were going. A hint that should show on hover and focus is a Tooltip, and a press that opens one is what makes the Popover work on touch screens.
Keyboard
| Key | Popover |
|---|---|
Enter, Space | On its trigger, open it, or close it when it's open |
Tab, Shift+Tab | Move between its controls, then on to what comes after its trigger, leaving it open |
Escape | Closes it, wherever focus is, and gives focus back to its trigger when it was inside |
Close it every way people expect
Every way a popover closes fires close, with a reason that says which:
| Reason | When |
|---|---|
escape | People press Escape, in the popover, on its trigger or anywhere else on the page |
outside | People press outside the popover and its trigger, which is light dismiss |
close-button | People press the close button |
trigger | People press its trigger again |
closed | Anything else, like another popover opening, or a close request like the back gesture on Android |
Unlike the Dialog, which asks before it closes, the Popover closes itself, and close says what already happened. With popover="auto", the browser closes it on a press outside and on Escape before any script hears about it, so there's nothing left to ask. That suits a popover, which holds nothing people would lose by closing it, like a set of filters that apply as they change. A form whose answers people could lose belongs in a Dialog, which can ask them first.
A press only closes it when it starts and ends outside, so people can select text in the popover and let go outside it (HTML Standard). A press on its trigger counts as the trigger's, so it closes the popover, rather than closing it and opening it again.
Close only the layer on top
Escape closes the layer on top, and goes no further. A Popover in a Dialog closes on its own, and the Dialog stays open, with focus in the popover or on its trigger, and a Tooltip in a Popover closes before the Popover does. Try it here: open the Dialog, then Permissions, then focus Guests, and press Escape three times.
<Dialog id="share-project" title="Share project" open={open} onClose={() => setOpen(false)} actions={<Button onClick={() => setOpen(false)}>Done</Button>}>
<p>Anyone with the link can view it.</p>
<Button popoverTarget="share-permissions" aria-haspopup="dialog" variant="outline">Permissions</Button>
<Popover id="share-permissions" title="Who can edit">
<Checkbox defaultChecked>Owners</Checkbox>
<Checkbox>Members</Checkbox>
<Tooltip label="Guests can only comment">
{(trigger) => <Checkbox {...trigger}>Guests</Checkbox>}
</Tooltip>
</Popover>
</Dialog>
Each layer takes Escape before the one below it, and stops it there:
- The Tooltip listens on the
document, in the capture phase, which comes before anything else, and stops the key once it has closed. - The Popover listens on itself and on its trigger, rather than on the
document, so a Tooltip inside it goes first. In a Dialog, it also listens on the Dialog's parts, which hear a key after the control that has focus and before the Dialog does, and on the Dialog itself while no control has focus, like after a click on text in the popover. It leaves alone a key that something has already cancelled, like a control that closes onEscapeitself, and one meant for a popover open inside it. Any otherEscapeit cancels and stops, which also keeps the browser from closing anything else, since a cancelledEscapeis no longer a close request (HTML Standard). - The Dialog listens on itself, and skips a key that's already been cancelled, which is how its own
Escaperule leaves room for the controls inside it.
Render a popover in a Dialog inside it, right after its trigger. An open Dialog makes everything outside it inert, and a popover rendered elsewhere, like at the end of the <body>, would open in the top layer but be inert, with nothing in it people could focus or press. When the Dialog closes from the keyboard, like with Enter on its Done button, the popover closes with it, and close fires with closed. A press on Done with a pointer is a press outside the popover, so it closes first, with outside.
Every state
Closed
A closed popover is hidden, so it isn't in the accessibility tree, and screen readers, agents and Tab can't find anything in it. It stays in the page while it's closed, so the Checkboxes inside keep what people picked, and so does a form, if they close it and open it again.
Open
An open popover sits beside its trigger, in the top layer, and follows it when the page scrolls. The page around it still works, so people can scroll, select text and use other controls. Pressing one of them closes the popover, since it's outside it, which is usually what people meant.
Keep to one popover at a time. Opening a popover closes any other one that's open, so two never cover the page at once. A popover opened from a control inside another one keeps the first one open, since it belongs to it, but more than two deep gets hard to follow.
Adaptive
Pointer, touch and pen
A tap opens a popover the same way a click does, and a tap outside it closes it. That's why the Popover, rather than the Tooltip, is the one to use for a hint people need on a touch screen.
Small screens
On a narrow screen, the popover is never wider than the screen, less 16px on each side, so on a phone 320px wide it's at most 288px, and still next to its trigger, below or above it when there's no room at its side, in browsers with CSS anchor positioning (WCAG 1.4.10). It's never taller than the screen less 32px either, nor than the room on the side it opens on, and when its content is taller than that, the body scrolls while the title stays in view. On phones, the on-screen keyboard covers the bottom of the screen too, so check that a focused input in a popover stays in view above it, or place that popover at the top.
It doesn't turn into a bottom sheet on small screens. A sheet across the bottom of the screen moves the content away from what opened it, and usually covers the page with a backdrop, which makes it a Dialog in all but name. If your content needs that much room on a phone, use a Dialog.
Motion
The popover fades in and grows from 96% as it opens, over 150ms. It only does when people haven't asked for less motion (prefers-reduced-motion: no-preference), and opens straight away otherwise (WCAG 2.3.3). It closes straight away either way, since people have already moved on.
More contrast
When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the panel's border is drawn in the text colour at full strength, rather than at 20%.
Forced colours
Windows contrast themes replace every colour with the user's own, and drop shadows. The panel's background and text become the theme's own, and its border is drawn in the theme's text colour, which is what sets it apart from the page once the shadow is gone. The arrow has the same border on its outer edges, so it stays visible too.
Colour scheme
The panel's background is Canvas, and its text reads tokens that use light-dark(), and both follow color-scheme, so the Popover adapts to your light and dark themes on its own, as long as they set it.
Text size and spacing
Sizes are in rem, so the panel grows with its text, at 200% text size (WCAG 1.4.4) and with wider line, letter and word spacing (WCAG 1.4.12). A long title wraps rather than getting cut off, and the body scrolls when there's no more room.
Languages and direction
start and end follow the reading direction, so a popover at the end of a button opens on its right in English and on its left in Arabic, without any change to your code. The Popover uses logical properties too, so the close button moves to the left in right-to-left languages. Translate its label with closeLabel, like closeLabel="إغلاق" in Arabic, since screen readers read it and voice control users say it.
UX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
popover/native-popover | Must | Component | It's an element with popover="auto", which a popovertarget button opens before any script loads, and never a scripted overlay | Open Components, beyond WCAG 2.1.1 (A) | Unit test |
popover/non-modal | Must | Component | The page around it keeps working: nothing is inert, nothing is aria-modal, and Tab can leave it | WAI-ARIA, WCAG 2.1.2 (A) | Unit test, keyboard |
popover/named | Must | Component | It's role="dialog", named by its title with aria-labelledby | WCAG 4.1.2 (A) | Unit test |
popover/visible-title | Must | Usage | Every popover has a title that says what it holds, like "Filters" | WCAG 1.3.1 (A), 2.4.6 (AA) | Review |
popover/trigger | Must | Usage | Its trigger is a button that points at it with popovertarget, and sets aria-haspopup="dialog" | WCAG 4.1.2 (A), HTML | ARIA snapshot |
popover/trigger-expanded | Must | Component | It keeps aria-expanded on its trigger in step with whether it's open, true only on the trigger that opened it | WCAG 4.1.2 (A) | Unit test, ARIA snapshot |
popover/after-trigger | Should | Usage | It's rendered right after its trigger, so Tab and screen readers reach it next | WCAG 1.3.2, 2.4.3 (A) | Keyboard |
popover/opens-on-press | Must | Usage | It opens from a press on its trigger, or from your code, and never on hover or focus alone | Open Components | Review |
popover/initial-focus | Must | Both | Opening it moves focus inside, to the control marked with data-autofocus, or else the first in its body, then its close button | WCAG 2.4.3 (A) | Unit test |
popover/return-focus | Must | Both | Closing it while focus is inside moves focus back to its trigger, and a press outside leaves focus where people pressed | WCAG 2.4.3 (A), HTML | Unit test, keyboard |
popover/escape | Must | Component | Escape closes it, with focus in it, on its trigger or anywhere else on the page | HTML, WCAG 2.4.11 (AA) | Unit test |
popover/contain-escape | Must | Component | An Escape that closes it stops there, so a Dialog around it stays open, while one a Tooltip inside it took closes only the Tooltip | Open Components | Unit test |
popover/light-dismiss | Must | Component | A press that starts and ends outside it and its trigger closes it, and one inside it doesn't | HTML | Unit test |
popover/close-button | Must | Component | It has a visible close button, a Button named "Close" | Open Components | Unit test |
popover/one-at-a-time | Should | Component | Opening a popover closes any other one, unless it opened from inside that one | HTML | Keyboard |
popover/inside-dialog | Must | Usage | A popover in a Dialog is rendered inside it, so the Dialog doesn't make it inert | HTML | Keyboard |
popover/right-component | Should | Usage | It holds a few controls or a short hint, while a hint on hover is a Tooltip and anything that needs an answer is a Dialog | Open Components | Review |
FAQ
Popover or something else?
A popover is one of a few things that open on top of the page, and each one suits a different job:
| When you need to… | Use |
|---|---|
| Name an icon-only button, or add a hint, on hover and focus | Tooltip |
| Show a few options, a short form or a hint with a link, next to its button | Popover |
| Show a hint people need on a touch screen too | Popover, as a toggletip |
| Offer a few actions behind one button | Dropdown Menu |
| Suggest values as people type | Combobox |
| Ask a question people answer before they go on | Dialog |
The Dropdown Menu and the Combobox are on our Roadmap. They'll open, flip and close the way the Popover does, with what's inside them their own.
Escape closes the inner one first. Opening any other popover closes both. More than two deep gets hard to follow, so try to keep to that.<h2> in it would land in the middle of the page's outline wherever you use it, at a level that's often wrong. Its title names it all the same, and that name is what screen readers announce as focus moves in. If your popover holds sections of its own, give them headings at the level that fits where it is.popovertarget already links the trigger to its popover, and gives it its expanded state. Browsers also expose it as a details relation when the popover isn't right after its trigger (HTML-AAM). aria-controls adds little on top, since most screen readers don't announce it, but it does no harm if your design system sets it on every popup.