Components

Popover

How to build a popover that shows more next to what opened it, leaves the rest of the page working, opens and closes the way people expect and can be checked by your agents.

Popovers show more next to the button that opened them, like a set of filters, a short form or a hint with a link, while the rest of the page keeps working. They sit between a tooltip, which only hints, and a dialog, which takes over the page, so they inherit the hard parts of both: focus, Escape and the top layer. Think of a popover that keyboard users can open but never reach, one whose Escape closes the dialog behind it as well, or one that opens off the edge of a phone's screen.

This page walks you through building one the right way. We'll look at:

  • User Interface (UI) - how a popover looks and where it opens
  • User Experience (UX) - how it behaves for every person and every input
  • Developer Experience (DX) - how developers work with it
  • Agentic Experience (AX) - how agents can read and verify it

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to open them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Popover with the same API in each, apart from two names in Angular, which Type-safe explains. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

The Popover is the non-modal sibling of the Dialog and the interactive sibling of the Tooltip. It keeps the rules they settled for focus, Escape and the top layer, and the Dropdown Menu and the Combobox on our Roadmap will build on the ones it settles for placement and closing.

<Button popoverTarget="playground-filters" aria-haspopup="dialog" variant="outline">Filters</Button>
<Popover id="playground-filters" title="Filters">
  {/* …a Checkbox for each filter */}
</Popover>

At a glance

AspectPopover
ElementA <div popover="auto">, rendered right after its trigger, and in the top layer while it's open
Roledialog, without aria-modal, since the page around it keeps working
NameIts title, linked with aria-labelledby
TriggerA Button with popovertarget and aria-haspopup="dialog", which the Popover gives aria-expanded
KeyboardEnter or Space on the trigger opens it, focus moves in, and Escape closes it and gives focus back
PointerA press on the trigger opens and closes it, and a press outside it closes it
StatesOpen and closed, as data-state
Placementtop, bottom, start or end of its trigger, flipping to the other side when there's no room
PatternThe dialog role in WAI-ARIA, and the APG's Disclosure for its trigger
WCAG 2.21.3.1, 1.3.2, 1.4.3, 1.4.10, 2.1.1, 2.1.2, 2.4.3, 2.4.11 and 4.1.2, among others

User Interface (UI)

Let's start with how a popover looks: its parts, where it opens, what goes in it, its states and the variables you can set.

Anatomy

  1. Trigger — The Button that opens it, with popovertarget. It's yours, and it keeps its own label.
  2. Panel — The popover itself, a <div> in the top layer while it's open, which holds every other part.
  3. Title — A few words on what it holds, like "Filters". It names the popover.
  4. Close button — A Button, named "Close", for people who can't press Escape, like on a phone.
  5. Body — Optional. The content, like a few options, a short form or a hint, which scrolls when it doesn't fit.
  6. Offset — The gap between the trigger and the panel.
  7. Arrow — Off by default. A small point on the edge that faces the trigger, hidden from assistive technologies.

Every popover has a panel, a title and a close button, and it always renders right after its trigger. That's what puts its content next in the reading order, so Tab and screen readers reach it straight after the button that opened it (WCAG 1.3.2, 2.4.3). You can see the order of its parts in the DOM contract.

Placement

The placement prop says which side of its trigger the popover opens on, and bottom is the default. start and end follow the reading direction, so start is on the left in English and on the right in Arabic. When there isn't room on that side, like for a button at the bottom of the window, the popover flips to the other side.

A start or end popover with room on neither side, like on a phone, opens below its trigger, or above it. When there's still no room, like on a short screen or at 400% zoom, it shrinks to fit on a side with about half the screen or more, and its body scrolls. So in browsers with CSS anchor positioning, it always stays inside the viewport and never covers its trigger. Older ones, Chrome and Edge before 129, Firefox before 147 and Safari before 26, keep it inside the viewport too, but a tall popover can cover its trigger there.

<Button popoverTarget="placement-top" aria-haspopup="dialog" variant="outline">Top</Button>
<Popover id="placement-top" title="Top" placement="top">
  <p>It opens above the button.</p>
</Popover>
{/* …bottom, start and end */}

We recommend bottom for most popovers, which is where Radix and Base UI open theirs by default too. A popover is something people asked for and are about to use, so it opens where their eyes and their hand go next, below the button, the way a select's list does. That's why it differs from the Tooltip's top, which keeps a hint clear of what's below its trigger. Side placements suit triggers stacked on top of each other, like the buttons of a vertical toolbar.

The popover opens in the top layer, with popover="auto", above every other element on the page, an open Dialog included. That way, no overflow: hidden or z-index around its trigger can clip or cover it, and it can stay right after its trigger in the page, rather than moving to the end of the <body>.

CSS anchor positioning then places it beside its trigger (position-area) and flips it when there's no room (position-try-fallbacks), the same way as the Tooltip. Its last fallbacks, @position-try rules, send a start or end popover below or above its trigger, and cap its height at the room on the side it opens on, as long as that's about half the screen. Browsers that don't have anchor positioning yet get the same side from a few lines of script, which you'll find in place() in the reference implementation, without the cap on its height.

The arrow is off by default, which differs from the Tooltip too. A popover is bigger than the button that opened it and opens right against it, so it's already clear which button it belongs to. Turn it on with arrow where it isn't, like for a toggletip in a line of text, and set its size with --popover--arrow--size. It points at the trigger's centre, and moves to the other edge when the popover flips.

Title and content

A popover's content goes in two places: a title at the top, beside the close button, and the body below them. Here's the Filters button from the Button page's Composable section, with its popover:

<Button popoverTarget="filters" aria-haspopup="dialog" variant="outline">Filters</Button>
<Popover id="filters" title="Filters">
  <Checkbox defaultChecked>Open issues only</Checkbox>
  <Checkbox>Assigned to me</Checkbox>
  <Checkbox>Has a pull request</Checkbox>
</Popover>
  • The title says what the popover holds, in a word or two, like "Filters" or "Share link". It's also the popover's name, so it's what screen readers announce as focus moves in, and what voice control users and agents find it by. It often repeats the trigger's label, and that's fine, since it tells people they've arrived where they meant to go.
  • The body is for a few controls or a few sentences, like the options above, a short form or a hint with a link. A popover sits on top of the page without blocking it, so anything that needs people's full attention, or more than a screenful, belongs in a Dialog or on a page of its own.
  • The close button is an icon-only, ghost Button named "Close", which closeLabel translates. Escape and a press outside close a popover too, but not every device has a key to press, and a screen reader on a phone can't press outside the popover without leaving it first.

Toggletips

A toggletip is a small button, often an "i" icon, that shows a hint when it's pressed, which makes it a Popover rather than a Tooltip. Unlike a tooltip, it works on touch screens, and it can hold a link, like the one below. It's also where the arrow earns its place, since the button it points at is small and sits in a line of text:

Billing cycle
<span>Billing cycle</span>
<Button popoverTarget="billing-cycle" aria-haspopup="dialog" label="About billing cycles" variant="ghost" size="sm" iconOnly leading={<InfoIcon />} />
<Popover id="billing-cycle" title="Billing cycles" arrow>
  <p>You're billed on the day you signed up, every month. <a href="/billing">How billing works</a></p>
</Popover>

Name the button by what it explains, like "About billing cycles", rather than "Info" or "More", since a page with three of them would otherwise have three buttons that sound the same. Some toggletips announce their text through a live region and keep focus on the button, like Inclusive Components describes. Ours moves focus in, like every Popover, which is what lets it hold a link people can reach.

States

A popover is either closed or open, and it shows which with data-state, so your styles and tests can read it in every browser:

StateSelectorRecommended look
Closed[data-state="closed"]Hidden, by the browser's own styles, but still in the page
Open[data-state="open"], and :popover-openBeside its trigger, in the top layer
Opening@starting-styleA fade and a grow from 96% over 150ms, only when people haven't asked for less motion

:popover-open matches the moment it opens, while data-state follows a moment later, once the browser has fired its toggle event, so animate on :popover-open. Hover, focus and disabled belong to the controls inside a popover and to its trigger, like the Button's focus ring, which show them the same way they do everywhere else. You'll find how each state behaves under Every state.

Tokens

The panel looks like a smaller Dialog, with the same border, background and type. Only its padding, its corners and its shadow differ: there's no backdrop behind a popover to set it apart from the page, so a soft shadow does. Its background is Canvas, the background browsers give a document, which follows color-scheme, so it covers what's behind it in your light and dark themes alike.

Its text reads one theme token, --color--neutral-text, like the Dialog's. The Buttons and Checkboxes inside read their own tokens, so copy tokens.css from the Button into your theme. With our reference tokens, the title and the content come to 14.9:1 on white, the same as in the Dialog. If you change the tokens or the background, make sure to check the same pair.

If you need to tune a popover, you can set its own variables from a class:

VariableDefaultWhat it sets
--popover--max-width20remHow wide it gets, at most, and never wider than the screen less 32px
--popover--padding1remThe space inside the panel
--popover--gap0.75remThe space between the header and the body
--popover--radius0.5remThe corners
--popover--border-colorcolor-mix(in srgb, currentColor 20%, transparent)The panel's border
--popover--backgroundCanvasThe panel's background
--popover--box-shadow0 0.25rem 1rem color-mix(in srgb, #000 15%, transparent)The shadow that lifts it off the page
--popover--offset0.5remThe gap between the trigger and the popover
--popover--arrow--size0.75remThe arrow's width and height, before it's turned, when it has one

The Popover's styles sit in the components cascade layer, so your class wins without any specificity tricks. Classes you pass to the Popover land on the popover itself:

ShareLink.tsx
import styles from "./ShareLink.module.css";

export function ShareLink() {
  return (
    <Popover id="share-link" title="Share link" className={styles.wide}>
      …
    </Popover>
  );
}
ShareLink.module.css
.wide {
  --popover--max-width: 28rem;
}

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 readerThe 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:

  1. The control you mark with data-autofocus, or with autofocus.
  2. Or else, the first control in its body, like the first checkbox of a set of filters.
  3. 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 a sm Button, 32 × 32px, above the 24 × 24px minimum (WCAG 2.5.8).

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

KeyPopover
Enter, SpaceOn its trigger, open it, or close it when it's open
Tab, Shift+TabMove between its controls, then on to what comes after its trigger, leaving it open
EscapeCloses 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:

ReasonWhen
escapePeople press Escape, in the popover, on its trigger or anywhere else on the page
outsidePeople press outside the popover and its trigger, which is light dismiss
close-buttonPeople press the close button
triggerPeople press its trigger again
closedAnything 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.

Share project

Anyone with the link can view it.

<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 on Escape itself, and one meant for a popover open inside it. Any other Escape it cancels and stops, which also keeps the browser from closing anything else, since a cancelled Escape is 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 Escape rule 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.

Developer Experience (DX)

Next, let's look at how developers use the Popover. You point a Button at it with popovertarget, which opens it with no state of yours at all, and bind open only when your code needs to open or close it too.

import { Button } from "@/components/Button";
import { Checkbox } from "@/components/Checkbox";
import { Popover } from "@/components/Popover";

export function IssueFilters() {
  return (
    <>
      <Button popoverTarget="filters" aria-haspopup="dialog" variant="outline">Filters</Button>
      <Popover id="filters" title="Filters">
        <Checkbox name="open" defaultChecked>Open issues only</Checkbox>
        <Checkbox name="mine">Assigned to me</Checkbox>
      </Popover>
    </>
  );
}

There's no state to keep, since the browser keeps it. Vanilla and Astro work the same way as the rest, and a page whose script hasn't loaded yet still opens and closes the popover, with Escape and light dismiss, and the browser gives its trigger an expanded state in the accessibility tree.

Consistent

Once you've learned the Dialog and the Tooltip, you already know most of the Popover. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
openPropWhether it's showing, for anything that opens on top of the page
titlePropThe text that names it, as on the Dialog
placementPropThe side of the trigger it opens on: top, bottom, start or end, as on the Tooltip
arrowPropDraws an arrow that points at the trigger, as on the Tooltip
closeLabelPropThe close button's label, for other languages, as on the Dialog
closeEventIt closed, and why

We recommend placement="bottom" and no arrow as the defaults, which differ from the Tooltip's, as Placement explains. The Popover also leaves a few things out, on purpose:

  • There's no trigger slot. The button that opens a popover is a Button of yours, which points at it with popovertarget, so it works before any script loads, and keeps its own label and its own place on the page.
  • There's no modal prop. A popover that blocks the page is a Dialog.
  • There's no variant, color or size. A popover has no intent of its own, its content says what it's about, and --popover--max-width sets how wide it gets.
  • There's no prop to turn off light dismiss or Escape. Both are the browser's, and both are how people expect a popover to close. Something that should stay open until people answer it is a Dialog.

Type-safe

Props, events and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like placement="left", which isn't one of the Popover's placements, or a popover without a title.

PropTypeDefaultDescription
openbooleanfalseWhether it's open. It follows every open and close, so bind it both ways
idstringRequired. Its trigger's popovertarget points to it, and its title's ID starts with it
titlestringRequired. The text that names the popover
placement"top" | "bottom" | "start" | "end""bottom"The side of the trigger it opens on, before it flips
arrowbooleanfalseDraws an arrow that points at the trigger
closeLabelstring"Close"The close button's label, for other languages
EventPayloadDescription
close"escape" | "outside" | "close-button" | "trigger" | "closed"Fires once it has closed, with why, unless your code closed it with open
toggleToggleEventThe browser's own, with newState, for every open and close

Angular is the one framework where two of these names change, since its Popover sits on the element itself, as the Dialog does. A title attribute stays on the element even when an input reads it, which would show the title as a tooltip over the whole panel, so the title is popoverTitle. The event is closeRequest, the same as the Dialog's, so the two read alike.

SlotDescription
defaultThe content, like a few options, a short form or a hint with a link, which scrolls when it doesn't fit

We also export PopoverProps and PopoverCloseReason, which come in handy for wrappers:

import { Popover, type PopoverProps } from "@/components/Popover";

export function FilterPopover(props: PopoverProps) {
  return <Popover placement="bottom" {...props} />;
}

Composable

The Popover renders the popover element as its root, so everything you pass lands on it, including classes, styles, data attributes and event listeners, the browser's toggle among them. Its title gets its ID from the popover's own id, as in filters-title, so you can point at it from elsewhere, like a test.

Finding its trigger

The Popover needs its trigger, to place itself beside it and to give it focus back. When it opens, it takes the button the browser says opened it, ToggleEvent.source, which every browser has had since 2026, or else the first button that points at it with popovertarget. That's why every trigger points at its popover, even one your code opens, rather than relying on where it sits in the page. Only the trigger that opened it carries the anchor's name, and only while it's open, so a popover with two triggers, like a toolbar's and a menu's, opens beside the one people pressed.

With other components

  • A Button opens the popover, with popovertarget and aria-haspopup="dialog", and the Popover keeps its aria-expanded in step, and gives it focus back when it closes. The close button is a Button too.
  • Checkboxes, Inputs and links in the body work as they do anywhere else, and the first of them gets focus when it opens.
  • A Tooltip inside a popover opens on top of it, and closes on Escape before it does.
  • A Dialog around a popover stays open when Escape closes the popover, as long as the popover is rendered inside it.
  • The Dropdown Menu and the Combobox on our Roadmap will open the same way: in the top layer, beside their trigger with the same placement names and flipping, closing on Escape and on a press outside, and giving focus back to their trigger. What's inside them is their own, like a menu's arrow keys or a combobox's list, which keeps focus in its input.

Controllable

The Popover doesn't keep any state you can't reach. Whether it's open is yours to bind, or to leave to its trigger and the browser, and everything else is a prop or an attribute you control:

StateHow you control it
OpenIts trigger, or open, which follows every open and close, so bind it both ways
Closingclose, with why, once it has closed
Initial focusdata-autofocus on the control to start on
Focus afterBack to the trigger on its own, or anywhere you move it once it's closed
Placementplacement, before it flips, and arrow
LookIts variables, from a class

Bind open when your code needs to close it, like once a short form inside it is sent. Its trigger still opens it, and open follows, so there's only ever one answer to whether it's open:

const [open, setOpen] = useState(false);

<Button popoverTarget="new-label" aria-haspopup="dialog" variant="outline">New label</Button>
<Popover id="new-label" title="New label" open={open} onToggle={(event) => setOpen(event.newState === "open")}>
  <form onSubmit={(event) => { event.preventDefault(); createLabel(); setOpen(false); }}>
    <label htmlFor="label-name">Name</label>
    <Input id="label-name" />
    <Button type="submit" color="primary">Create label</Button>
  </form>
</Popover>

React and Solid follow the browser's toggle event, whose newState is open or closed, the same way they follow a Checkbox's change. Vue, Svelte and Angular bind open both ways. Your code and its trigger can both open and close it, and whichever acts last wins, since neither is ever turned down. Setting open fires no close, since your code already knows why.

Agentic Experience (AX)

Finally, let's look at how agents read, open and check a popover. They rely on the same accessibility tree as assistive technologies, and on a contract they can check the code against.

Semantic

Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names and states. This is how Playwright sees the Filters popover once it's open, leaving out the checkboxes' text:

- button "Filters" [expanded]
- dialog "Filters":
  - paragraph: Filters
  - button "Close"
  - checkbox "Open issues only" [checked]
  - checkbox "Assigned to me"
  - checkbox "Has a pull request"

Playwright works roles and states out for itself, from attributes, so it reads the trigger's aria-expanded, which the Popover sets, but not its aria-haspopup. The browser's own tree, which screen readers read and Chrome DevTools shows under Accessibility, has both. While it's closed, the trigger reads as collapsed, and the popover isn't in the tree at all. With a role and a name on both, an agent can open the popover and act inside it:

await page.getByRole("button", { name: "Filters" }).click();
const filters = page.getByRole("dialog", { name: "Filters" });
await filters.getByRole("checkbox", { name: "Assigned to me" }).check();
await page.keyboard.press("Escape");

Described

Here's the whole contract in one block, for agents to read before they build or review a popover:

component: Popover
summary: Shows more next to the button that opened it, like filters or a short form, without blocking the rest of the page. Its title names it, and a Button with popovertarget opens it.
standard: https://opencomponents.dev/docs/components/popover
element: div # popover="auto", right after its trigger, and in the top layer while it's open
role: dialog # without aria-modal, since the page around it keeps working
props:
  open: { type: boolean, default: false } # it follows every open and close, so bind it both ways
  id: { type: string } # required. Its trigger's popovertarget points to it, and its title's ID starts with it
  title: { type: string } # required
  placement: { type: [top, bottom, start, end], default: bottom }
  arrow: { type: boolean, default: false }
  closeLabel: { type: string, default: Close }
slots:
  default: The content, like a few options, a short form or a hint with a link, which scrolls when it doesn't fit.
events:
  close: "escape, outside, close-button, trigger or closed. Fires once it has closed, unless open closed it."
  toggle: "The browser's own ToggleEvent, with newState open or closed, for every open and close."
states:
  open: "[data-state=open], and :popover-open"
  closed: "[data-state=closed], hidden by the browser's own styles"
keyboard:
  Enter: On its trigger, opens it, or closes it when it's open. Focus moves to data-autofocus, or else the first control in its body, then its close button. The trigger's aria-expanded follows.
  Space: On its trigger, the same as Enter.
  Tab: Moves between its controls, then on to what comes after its trigger, leaving it open.
  Escape: Closes it, wherever focus is, and stops there, giving focus back to its trigger when it was inside. A popover open inside it, or a control that handles Escape itself, goes first.
parts: [header, title, close, body, arrow] # data-slot, in this order. The arrow is only there with arrow
tokens:
  theme: ["--color--neutral-text"]
  variables: [--popover--max-width, --popover--padding, --popover--gap, --popover--radius, --popover--border-color, --popover--background, --popover--box-shadow, --popover--offset, --popover--arrow--size]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/popover#checklist

It's also published on its own at /raw/docs/components/popover.yaml, with every rule from the checklist in place of the rules link. It's a fraction of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them.

Every rule has a stable ID, like popover/contain-escape, so you and your agents can cite it in reviews and commits.

Deterministic

The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's a popover with a short hint:

<Popover id="filters" title="Filters">
  <p>Pick what to show.</p>
</Popover>

And here's what it renders, before it opens:

<div class="popover" id="filters" role="dialog" aria-labelledby="filters-title" popover="auto" data-state="closed" data-placement="bottom">
  <div data-slot="header">
    <p id="filters-title" data-slot="title">Filters</p>
    <button class="button" type="button" data-variant="ghost" data-color="neutral" data-size="sm" data-icon-only data-slot="close">
      <span data-slot="leading" aria-hidden="true"><svg>…</svg></span>
      <span data-slot="label">Close</span>
    </button>
  </div>
  <div data-slot="body">
    <p>Pick what to show.</p>
  </div>
</div>
  • data-state and data-placement are always there. With arrow, the popover ends with a <span data-slot="arrow" aria-hidden="true">, and once it's open, data-side says which side of the trigger it's on, so the arrow can point back. It's a physical side, top, bottom, left or right, so start comes out as left or right by direction.
  • Parts are marked with data-slot and always come in the same order: header, holding title and close, then body and arrow. Only the body and the arrow can be left out.
  • The title's ID is the popover's own, with -title on the end. There's nothing random in the server's markup, so it's the same on the server, on the client and in every snapshot.
  • Once it mounts, every trigger that points at it gets aria-expanded="false", and the one that opens it "true" while it's open. The popover gets position-anchor in its style, and the trigger that opens it gets the matching anchor-name until it closes. The name comes from your framework's ID helper, since an id can hold characters a CSS name can't, like 1:2. In browsers without CSS anchor positioning, an open popover also gets top and left, from the script that places it.
  • Opening and closing change attributes, never elements. The popover stays in the page while it's closed, so a form inside it keeps what people typed.

Verifiable

Every rule in the checklist can be checked, and about half of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in Popover.test.ts, which you'll find under Reference implementation. We run them on every change to this page, so the code you see always passes them.

They run in jsdom, which has no Popover API, so the tests stand in for it: showing and hiding with beforetoggle and toggle, a popovertarget button opening its popover, and the closes the browser makes itself, for Escape and for a press outside. The Escape tests mount a real Dialog and a real Tooltip. A stand-in can't put the popover in the top layer or place it, so the checklist checks those in the browser instead.

In the browser

Placement, flipping, the top layer and light dismiss only exist in a real browser, so check them there. You can use the keyboard and the pointer the way people would, and run axe over the open popover:

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("the Filters popover takes focus, closes on Escape and gives focus back", async ({ page }) => {
  await page.goto("/issues");

  const trigger = page.getByRole("button", { name: "Filters" });
  await trigger.focus();
  await page.keyboard.press("Enter");

  const filters = page.getByRole("dialog", { name: "Filters" });
  await expect(trigger).toHaveAttribute("aria-haspopup", "dialog");
  await expect(filters.getByRole("checkbox", { name: "Open issues only" })).toBeFocused();

  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(violations).toEqual([]);

  await page.keyboard.press("Escape");
  await expect(filters).toBeHidden();
  await expect(trigger).toBeFocused();

  // A press outside closes it too, and leaves focus where it landed.
  await trigger.click();
  await page.mouse.click(5, 5);
  await expect(filters).toBeHidden();
});

Some things still need a human, so try your popovers near every edge of the window and on a phone, check that they flip and stay inside the screen, and use them with a screen reader, which should read the title as focus moves in. Try them at 200% zoom and with forced colours, more contrast and reduced motion turned on too. You can emulate the last three in Chrome DevTools, under Rendering.

During development

Our reference implementation also warns you in the console as soon as a popover renders without a title, which leaves it without a name.

Prompts

You can point your agent straight at this page. To build a popover, you could use:

Build a Popover component for our React design system that meets the Open Components
Popover standard: https://opencomponents.dev/raw/docs/components/popover.md

Follow its API, its DOM contract and its tokens. Port its tests and make them pass.
Then check your work against every rule in its checklist with a Component or Both
scope, and cite the rule ID for any rule you can't meet.

To review one, the contract is enough:

Review our Popover against the Open Components Popover contract:
https://opencomponents.dev/raw/docs/components/popover.yaml

For each rule with a component or both scope, report pass or fail with the rule ID
and the evidence from our code. Then fix the failures.

And to review how a screen uses its popovers:

Review how our issues page uses its popovers against the Open Components Popover
contract: https://opencomponents.dev/raw/docs/components/popover.yaml

For each rule with a usage or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A popover meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Popover itself, so they're the ones to check when you build or review one. Usage rules are met by the screens that use it, like pointing its trigger at it with popovertarget, so they're the ones to check when you review a screen. Both rules need the two to work together, like focus going back to the trigger: the Popover gives it back, as long as the trigger points at it.

The basis says where a rule comes from. Many of the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and several come from HTML and WAI-ARIA. The rest are Open Components rules, which are our own, like closing only the layer on top. Where a rule goes further than WCAG asks at level AA, its basis says so: one comes from level AAA, reduced motion, and a rule that asks for more than its criterion says beyond, like using the popover attribute, where WCAG would let you rebuild one by hand.

UI rules

RuleLevelScopeRequirementBasisCheck
popover/contrastMustComponentThe title and the content have a contrast of 4.5:1 against the panelWCAG 1.4.3 (AA)axe color-contrast, contrast checker
popover/edgeShouldComponentA border and a shadow set the panel apart from the page behind itOpen ComponentsReview
popover/top-layerMustComponentIt opens in the top layer, so no overflow or z-index around its trigger clips or covers it, above an open Dialog tooHTMLVisual regression test
popover/flipsMustComponentIt opens beside its trigger, on the side placement names, flips to the other side when there's no room and stays inside the viewport, never covering its trigger where the browser has CSS anchor positioningOpen ComponentsVisual regression test
popover/fallback-positionShouldComponentBrowsers without CSS anchor positioning get the same side from a script and stay inside the viewport, although a tall popover can cover its trigger thereOpen ComponentsUnit test, visual regression test
popover/points-at-triggerMustComponentAn arrow, when there is one, sits on the edge facing the trigger and points at its centre, after a flip tooOpen ComponentsUnit test, visual regression test
popover/fits-screenMustComponentIt's never wider or taller than the screen less 16px on each side, down to 320px wide, and its body scrolls when its content doesn't fitWCAG 1.4.10 (AA)Emulation
popover/resizableMustComponentIt's sized in rem, so it grows with its text, and its title wraps rather than getting cut offOpen Components, beyond WCAG 1.4.4, 1.4.12 (AA)200% zoom
popover/reduced-motionShouldComponentIt only animates as it opens when prefers-reduced-motion is no-preferenceWCAG 2.3.3 (AAA)Emulation
popover/forced-colorsMustComponentThe panel's border stays visible in forced colours mode, which drops its shadowOpen ComponentsEmulation
popover/more-contrastShouldComponentThe panel's border is drawn at full strength when prefers-contrast: more is setOpen ComponentsEmulation
popover/follows-directionShouldComponentstart and end follow the reading direction, and its close button mirrors in right-to-left languagesOpen ComponentsUnit test, review

UX rules

RuleLevelScopeRequirementBasisCheck
popover/native-popoverMustComponentIt's an element with popover="auto", which a popovertarget button opens before any script loads, and never a scripted overlayOpen Components, beyond WCAG 2.1.1 (A)Unit test
popover/non-modalMustComponentThe page around it keeps working: nothing is inert, nothing is aria-modal, and Tab can leave itWAI-ARIA, WCAG 2.1.2 (A)Unit test, keyboard
popover/namedMustComponentIt's role="dialog", named by its title with aria-labelledbyWCAG 4.1.2 (A)Unit test
popover/visible-titleMustUsageEvery popover has a title that says what it holds, like "Filters"WCAG 1.3.1 (A), 2.4.6 (AA)Review
popover/triggerMustUsageIts trigger is a button that points at it with popovertarget, and sets aria-haspopup="dialog"WCAG 4.1.2 (A), HTMLARIA snapshot
popover/trigger-expandedMustComponentIt keeps aria-expanded on its trigger in step with whether it's open, true only on the trigger that opened itWCAG 4.1.2 (A)Unit test, ARIA snapshot
popover/after-triggerShouldUsageIt's rendered right after its trigger, so Tab and screen readers reach it nextWCAG 1.3.2, 2.4.3 (A)Keyboard
popover/opens-on-pressMustUsageIt opens from a press on its trigger, or from your code, and never on hover or focus aloneOpen ComponentsReview
popover/initial-focusMustBothOpening it moves focus inside, to the control marked with data-autofocus, or else the first in its body, then its close buttonWCAG 2.4.3 (A)Unit test
popover/return-focusMustBothClosing it while focus is inside moves focus back to its trigger, and a press outside leaves focus where people pressedWCAG 2.4.3 (A), HTMLUnit test, keyboard
popover/escapeMustComponentEscape closes it, with focus in it, on its trigger or anywhere else on the pageHTML, WCAG 2.4.11 (AA)Unit test
popover/contain-escapeMustComponentAn Escape that closes it stops there, so a Dialog around it stays open, while one a Tooltip inside it took closes only the TooltipOpen ComponentsUnit test
popover/light-dismissMustComponentA press that starts and ends outside it and its trigger closes it, and one inside it doesn'tHTMLUnit test
popover/close-buttonMustComponentIt has a visible close button, a Button named "Close"Open ComponentsUnit test
popover/one-at-a-timeShouldComponentOpening a popover closes any other one, unless it opened from inside that oneHTMLKeyboard
popover/inside-dialogMustUsageA popover in a Dialog is rendered inside it, so the Dialog doesn't make it inertHTMLKeyboard
popover/right-componentShouldUsageIt holds a few controls or a short hint, while a hint on hover is a Tooltip and anything that needs an answer is a DialogOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
popover/shared-vocabularyMustComponentIt uses the shared names: open, title (popoverTitle in Angular), placement, arrow, closeLabel and close (closeRequest in Angular)Open ComponentsReview
popover/typedMustComponentProps, events and slots are typed, with unions rather than string, and id and title are requiredOpen ComponentsType check
popover/controllableMustComponentopen opens and closes it, and follows every other open and close, each of which fires close with whyOpen ComponentsUnit test
popover/root-attributesMustComponentAttributes, classes and listeners land on the popover elementOpen ComponentsUnit test

AX rules

RuleLevelScopeRequirementBasisCheck
popover/role-and-nameMustComponentIt can be found by its role and title alone, with getByRole("dialog", { name })WCAG 4.1.2 (A)Unit test, ARIA snapshot
popover/hidden-when-closedMustComponentA closed popover isn't in the accessibility tree, so neither agents nor screen readers find itHTMLUnit test
popover/deterministic-domMustComponentThe same props render the same markup, with its title's ID made from its own, and opening it changes attributes, never elementsOpen ComponentsUnit test
popover/data-attributesShouldComponentIts state and placement are mirrored as data-state and data-placement, and parts are marked with data-slotOpen ComponentsUnit test
popover/dev-warningsShouldComponentIt warns during development when it has no titleOpen ComponentsUnit test

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves: opening from its trigger, moving focus in and back out, and closing only the layer on top. It uses the Button for its close button, so copy the Button and its tokens.css into your theme too.

The tests are in Popover.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom. jsdom doesn't have the Popover API yet, so the tests stand in for it, at the top of the file. They also mount the Dialog and the Tooltip, for the Escape tests.

<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, useId, useTemplateRef, watch } from "vue";
import Button from "../button/Button.vue";

/**
 * Why the popover closed: people pressed `Escape`, pressed or clicked outside it, pressed
 * its close button or its trigger again, or `closed` for anything else, like another
 * popover opening. It has already closed by the time `close` fires.
 */
export type PopoverCloseReason = "escape" | "outside" | "close-button" | "trigger" | "closed";

export interface PopoverProps {
  /** The popover's ID, which its trigger's `popovertarget` points to, and its title's starts with. */
  id: string;
  /** The text that names the popover, like "Filters", which screen readers read as focus moves in. */
  title: string;
  /** The side of the trigger it opens on. It flips to the other side when there's no room. */
  placement?: "top" | "bottom" | "start" | "end";
  /** Draws an arrow that points at the trigger, on whichever side it opens. */
  arrow?: boolean;
  /** The close button's label, which screen readers read and voice control users say. */
  closeLabel?: string;
}

const {
  id,
  title,
  placement = "bottom",
  arrow = false,
  closeLabel = "Close",
} = defineProps<PopoverProps>();

const emit = defineEmits<{
  /** It closed, and why, whether people closed it or the browser did. Never when you closed it with `open`. */
  close: [reason: PopoverCloseReason];
}>();

/**
 * Whether it's open, which `v-model:open` binds. Without it, the popover keeps its own, which
 * its trigger changes. It follows every open and close, its trigger's and the browser's too.
 */
const open = defineModel<boolean>("open", { default: false });

defineSlots<{
  /** The content, like a short form, a few options or a hint with a link. */
  default?: () => unknown;
}>();

const popover = useTemplateRef<HTMLElement>("popover");
const arrowPart = useTemplateRef<HTMLElement>("arrow-part");
const state = ref<"open" | "closed">("closed");
// The side it opened on, after any flip, which the arrow points from. Only aim() sets it,
// so a popover without an arrow never has one.
const side = ref<"top" | "bottom" | "left" | "right">();
// The anchor's name is set once it mounts, from a generated ID, since an id you pass can
// hold characters a CSS name can't, like "1:2", and the browser would drop the anchor.
const uid = useId();
const anchorName = ref<string>();
onMounted(() => (anchorName.value = `--popover-${uid}`));

// The button that opened it, which it's placed against and gives focus back to, and
// whatever had focus when it opened, for a popover your code opened without one.
let trigger: HTMLElement | null = null;
let opener: HTMLElement | null = null;
let frame: number | undefined;
// Whether it's showing, from `beforetoggle`, which the browser fires as it shows or hides
// it, while `toggle` comes a task later. So your code opening it right after closing it
// isn't mistaken for a popover that's still open.
let showing = false;
// The parts of a Dialog around it, which it listens on while it's open.
let hostParts: Element[] = [];

// Why it's closing. The popover closes itself on its close button and on Escape, and
// `null` means your code closed it, which fires no `close`. Every other close is the
// browser's, after a press outside it, Escape elsewhere on the page, its trigger or
// another popover, so the listeners below note what people just did, until the next task.
let closing: PopoverCloseReason | null | undefined;
let pending: PopoverCloseReason | undefined;
let reason: PopoverCloseReason | null = null;
let clearing: ReturnType<typeof setTimeout> | undefined;
let pressedOutside = false;
let returnFocus = false;

function note(next: PopoverCloseReason) {
  pending = next;
  clearTimeout(clearing);
  clearing = setTimeout(() => (pending = undefined));
}

const FOCUSABLE =
  'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';

// The Dialog's order: the control people marked with `data-autofocus`, or else the first
// one in the body, and only then the close button. Browsers already focus an `autofocus`
// control in a popover as it opens, so it counts as well.
function initialFocus(el: HTMLElement) {
  return (
    el.querySelector<HTMLElement>("[data-autofocus], [autofocus]") ??
    el.querySelector('[data-slot="body"]')?.querySelector<HTMLElement>(FOCUSABLE) ??
    el.querySelector<HTMLElement>('[data-slot="close"]')
  );
}

// The buttons that point at it with `popovertarget`.
const triggers = () => document.querySelectorAll<HTMLElement>(`[popovertarget="${id.replace(/["\\]/g, "\\$&")}"]`);

// The trigger is the button the browser says opened it, where it says so, or else the
// first one that points at it. TypeScript doesn't know `source` yet.
function findTrigger(event: Event) {
  const { source } = event as ToggleEvent & { source?: Element | null };
  return source instanceof HTMLElement ? source : (triggers()[0] ?? null);
}

// The browser gives a `popovertarget` button its expanded state, but only in the
// accessibility tree, so the Popover writes it out as `aria-expanded` too, which the
// Button's Expanded style and agents read.
function expand(el: HTMLElement | null, value: boolean) {
  el?.setAttribute("aria-expanded", String(value));
}

// Only the trigger that opened it carries the anchor's name, since with two buttons named
// the same, the browser would place it beside the last one on the page.
function unanchor() {
  trigger?.style.removeProperty("anchor-name");
}

function onBeforetoggle(event: Event) {
  const el = popover.value!;
  if ((event as ToggleEvent).newState === "open") {
    showing = true;
    // Opening it again from another trigger before the browser said it closed leaves the
    // last trigger as it would be once closed.
    listen(false);
    expand(trigger, false);
    unanchor();
    trigger = findTrigger(event);
    opener = document.activeElement as HTMLElement | null;
    if (trigger && anchorName.value) trigger.style.setProperty("anchor-name", anchorName.value);
    return;
  }
  showing = false;
  // Focus goes back to the trigger only when it was inside, like when people pressed
  // Escape or the close button. After a press outside, it stays where people pressed.
  returnFocus = el.contains(document.activeElement);
  reason = closing !== undefined ? closing : (pending ?? "closed");
  closing = undefined;
}

function onToggle(event: Event) {
  const el = popover.value!;
  if ((event as ToggleEvent).newState === "open") {
    state.value = "open";
    open.value = true;
    expand(trigger, true);
    listen(true);
    // Focus moves in, so keyboard and screen reader users start in the popover, unless
    // the browser already focused an `autofocus` control in it.
    if (!el.contains(document.activeElement)) initialFocus(el)?.focus();
    nextTick(update);
    if (arrow) nextTick(follow);
    return;
  }
  state.value = "closed";
  open.value = false;
  expand(trigger, false);
  unanchor();
  listen(false);
  // The browser gives focus back to whatever had it before the popover opened, which in
  // Safari, where a click doesn't focus a button, isn't the trigger. So the trigger gets it,
  // unless focus has already moved somewhere else, like into another popover.
  const active = document.activeElement;
  if (returnFocus && (!active || active === document.body || el.contains(active))) (trigger ?? opener)?.focus();
  returnFocus = false;
  if (reason) emit("close", reason);
}

// Escape closes the popover and goes no further, so a dialog around it stays open, whether
// focus is in the popover or on its trigger. It's handled on the popover itself, rather than
// the document, so a tooltip inside it, which stops Escape on the document, and a control
// that cancels it, close first and alone. Cancelling it also keeps the browser from closing
// anything else on it. With a popover open inside this one, Escape is that one's.
function onKeydown(event: KeyboardEvent) {
  if (event.key !== "Escape" || event.defaultPrevented || !showing) return;
  if (popover.value?.querySelector('[popover]:popover-open, .popover[data-state="open"]')) return;
  event.preventDefault();
  event.stopPropagation();
  dismiss("escape");
}

// Elsewhere on the page, the browser closes it on Escape. Inside a Dialog, though, the
// Dialog takes Escape first and cancels it, which would close the Dialog around a popover
// that's still open. So in a Dialog, it listens on the Dialog's parts too, which hear an
// Escape after the control that has focus and before the Dialog does.
function onHostKeydown(event: KeyboardEvent) {
  const target = event.target as Node;
  if (popover.value?.contains(target) || trigger?.contains(target)) return;
  onKeydown(event);
}

// With focus on the Dialog itself, like after a click on text in the popover, which can't
// take focus, none of its parts hears the key. So the Dialog does too, in the capture
// phase, but only then: with no control focused, it takes the key from nobody.
function onDialogKeydown(event: KeyboardEvent) {
  if (event.target === event.currentTarget) onKeydown(event);
}

// A Dialog that closes, like from its Done button, takes the popover with it.
function onHostClose() {
  if (showing) dismiss("closed");
}

function dismiss(why: PopoverCloseReason | null) {
  closing = why;
  popover.value?.hidePopover?.();
}

// What people do while it's open, which tells the closes the browser makes apart.
function onPointerdown(event: PointerEvent) {
  const target = event.target as Node;
  pressedOutside = !popover.value?.contains(target) && !trigger?.contains(target);
}
function onPointerup(event: PointerEvent) {
  const target = event.target as Node;
  if (pressedOutside && !popover.value?.contains(target) && !trigger?.contains(target)) note("outside");
  pressedOutside = false;
}
function onPageKeydown(event: KeyboardEvent) {
  if (event.key === "Escape" && !event.defaultPrevented) note("escape");
}
function onTriggerClick() {
  note("trigger");
}

function listen(on: boolean) {
  const method = on ? "addEventListener" : "removeEventListener";
  document[method]("pointerdown", onPointerdown as EventListener, true);
  document[method]("pointerup", onPointerup as EventListener, true);
  // On the window, after everything else, so it only hears an Escape nothing else took.
  window[method]("keydown", onPageKeydown as EventListener);
  trigger?.[method]("keydown", onKeydown as EventListener);
  trigger?.[method]("click", onTriggerClick);
  const host = popover.value?.parentElement?.closest("dialog");
  if (on) hostParts = [...(host?.children ?? [])];
  for (const part of hostParts) part[method]("keydown", onHostKeydown as EventListener);
  host?.[method]("keydown", onDialogKeydown as EventListener, true);
  host?.[method]("close", onHostClose);
  if (!on) hostParts = [];
  if (!anchored()) {
    window[method]("scroll", update, { capture: true, passive: true } as AddEventListenerOptions);
    window[method]("resize", update);
  }
  if (!on && frame !== undefined) {
    cancelAnimationFrame(frame);
    frame = undefined;
  }
}

// Your code opening and closing it. Once it's open or closed, the model says so too, which
// changes nothing here.
watch(
  open,
  (value) => {
    const el = popover.value;
    if (!el?.showPopover) return;
    if (value && !showing) el.showPopover();
    else if (!value && showing) dismiss(null);
  },
  { flush: "post" },
);

// CSS anchor positioning places it, where the browser has it. Elsewhere, place() does.
// It checks position-area rather than anchor-name, which Chrome had four versions earlier.
const anchored = () => globalThis.CSS?.supports?.("position-area: top") ?? false;

function update() {
  if (!anchored()) place();
  if (arrow) aim();
}

// The arrow follows it on every frame while it's open, since the browser moves an anchored
// popover after a scroll handler runs, and a flip can move it with no scroll at all.
function follow() {
  if (state.value !== "open") return;
  aim();
  frame = requestAnimationFrame(follow);
}

// Without anchor positioning: the chosen side of the trigger, or the other side when it
// doesn't fit, centred on the trigger and kept inside the viewport.
function place() {
  const el = popover.value;
  if (!el || !trigger) return;
  // Its margin is the gap, which `left` and `top` place it outside of.
  const gap = parseFloat(getComputedStyle(el).marginTop) || 0;
  const a = trigger.getBoundingClientRect();
  // Its exact size, without the scale it grows in from, which getBoundingClientRect() would include.
  const width = parseFloat(getComputedStyle(el).width);
  const height = parseFloat(getComputedStyle(el).height);
  const { clientWidth, clientHeight } = document.documentElement;
  const rtl = getComputedStyle(trigger).direction === "rtl";
  const sides = { top: "top", bottom: "bottom", start: rtl ? "right" : "left", end: rtl ? "left" : "right" } as const;
  const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" } as const;
  const fits = {
    top: a.top - gap - height >= 0,
    bottom: a.bottom + gap + height <= clientHeight,
    left: a.left - gap - width >= 0,
    right: a.right + gap + width <= clientWidth,
  };
  let at: keyof typeof opposite = sides[placement];
  if (!fits[at] && fits[opposite[at]]) at = opposite[at];
  // Beside a trigger with room on neither side, like on a phone, it goes below it or above it,
  // rather than over it.
  else if (!fits[at] && (at === "left" || at === "right")) at = fits.bottom || !fits.top ? "bottom" : "top";
  const across = at === "top" || at === "bottom";
  const x = across ? a.left + (a.width - width) / 2 : at === "left" ? a.left - gap - width : a.right + gap;
  const y = across ? (at === "top" ? a.top - gap - height : a.bottom + gap) : a.top + (a.height - height) / 2;
  el.style.left = `${Math.max(0, Math.min(x, clientWidth - width)) - gap}px`;
  el.style.top = `${Math.max(0, Math.min(y, clientHeight - height)) - gap}px`;
}

// With an arrow: the side it opened on, from where its centre is, which its scale doesn't move,
// and the arrow across from the trigger's centre, or as close as the rounded corners allow.
function aim() {
  const el = popover.value;
  const part = arrowPart.value;
  if (!el || !part || !trigger) return;
  const a = trigger.getBoundingClientRect();
  const p = el.getBoundingClientRect();
  const x = p.left + p.width / 2;
  const y = p.top + p.height / 2;
  side.value = y < a.top ? "top" : y > a.bottom ? "bottom" : x < a.left ? "left" : "right";
  const across = side.value === "top" || side.value === "bottom";
  const box = getComputedStyle(el);
  const length = parseFloat(across ? box.width : box.height);
  const centre = across ? a.left + a.width / 2 - (x - length / 2) : a.top + a.height / 2 - (y - length / 2);
  const clear = parseFloat(box.borderTopLeftRadius) + parseFloat(getComputedStyle(part).width) / Math.SQRT2;
  const offset = clear * 2 < length ? Math.max(clear, Math.min(centre, length - clear)) : length / 2;
  part.style.left = across ? `${offset - el.clientLeft}px` : "";
  part.style.top = across ? "" : `${offset - el.clientTop}px`;
}

onMounted(() => {
  for (const el of triggers()) expand(el, false);
  if (open.value) popover.value?.showPopover?.();

  if (import.meta.env.DEV && !title.trim()) {
    console.warn("[Popover] has no title: give it one, since it's what names the popover.", popover.value);
  }
});

onBeforeUnmount(() => {
  if (showing) dismiss(null);
  listen(false);
  expand(trigger, false);
  unanchor();
  clearTimeout(clearing);
});
</script>

<template>
  <!-- popover="auto" puts it in the top layer and lets the browser close it on Escape and on a
       press outside, and its trigger opens it with popovertarget, before any script loads. -->
  <div
    :id="id"
    ref="popover"
    class="popover"
    role="dialog"
    :aria-labelledby="`${id}-title`"
    popover="auto"
    :data-state="state"
    :data-placement="placement"
    :data-side="side"
    :style="anchorName && { 'position-anchor': anchorName }"
    @beforetoggle="onBeforetoggle"
    @toggle="onToggle"
    @keydown="onKeydown"
  >
    <div data-slot="header">
      <!-- Not a heading, since the popover sits among the page's own content, where an <h2>
           would break the page's outline. It names the popover all the same. -->
      <p :id="`${id}-title`" data-slot="title">{{ title }}</p>
      <Button
        data-slot="close"
        :label="closeLabel"
        variant="ghost"
        size="sm"
        icon-only
        @click="dismiss('close-button')"
      >
        <template #leading>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
            <path d="M18 6 6 18M6 6l12 12" />
          </svg>
        </template>
      </Button>
    </div>
    <div v-if="$slots.default" data-slot="body">
      <slot />
    </div>
    <span v-if="arrow" ref="arrow-part" data-slot="arrow" aria-hidden="true" />
  </div>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The popover's styles go in `components`,
   so a class of yours that sets its variables wins without any specificity tricks. */
@layer theme, base, components, utilities;

/* The panel looks like the Dialog's, a little smaller, with a shadow, since there's no
   backdrop to set it apart from the page. */
@layer components {
  .popover {
    --popover--max-width: 20rem;
    --popover--padding: 1rem;
    --popover--gap: 0.75rem;
    --popover--radius: 0.5rem;
    /* Decoration, like the Dialog's: its title says what it is, so it doesn't need 3:1. */
    --popover--border-color: color-mix(in srgb, currentColor 20%, transparent);
    /* The page's own background, which follows `color-scheme`, so it covers what's behind it. */
    --popover--background: Canvas;
    --popover--box-shadow: 0 0.25rem 1rem color-mix(in srgb, #000 15%, transparent);
    /* The gap between the trigger and the popover. */
    --popover--offset: 0.5rem;
    /* The arrow's width and height, before it's turned, when it has one. */
    --popover--arrow--size: 0.75rem;

    /* Beside the trigger, centred on it, and on the other side when there's no room. When
       neither side has room, it shrinks on one with about half the screen or more, and its
       body scrolls. */
    position: fixed;
    position-area: bottom;
    position-try-fallbacks: flip-block, flip-inline, --popover-fit, flip-block --popover-fit;
    inset: auto;
    margin: var(--popover--offset);
    box-sizing: border-box;
    flex-direction: column;
    gap: var(--popover--gap);
    /* Never wider or taller than the screen, less 16px on each side, so it fits at 320px wide. */
    width: max-content;
    max-width: min(var(--popover--max-width), 100vw - 2rem);
    max-height: calc(100dvh - 2rem);
    padding: var(--popover--padding);
    border: 1px solid var(--popover--border-color);
    border-radius: var(--popover--radius);
    /* The arrow sits half outside it, and the body scrolls instead. */
    overflow: visible;
    background-color: var(--popover--background);
    box-shadow: var(--popover--box-shadow);
    color: var(--color--neutral-text);
    font-family: inherit;
    font-size: 0.875rem;
    line-height: 1.5;
    overflow-wrap: break-word;
  }
  .popover:popover-open {
    display: flex;
  }
  .popover[data-placement="top"] {
    position-area: top;
  }
  /* In the popover's own direction, which is its trigger's, rather than the page's. */
  .popover[data-placement="start"] {
    position-area: self-inline-start;
  }
  .popover[data-placement="end"] {
    position-area: self-inline-end;
  }
  /* Beside a trigger with room on neither side, like on a phone, it goes below it or above it,
     rather than over it. */
  .popover:is([data-placement="start"], [data-placement="end"]) {
    position-try-fallbacks: flip-inline, bottom, top, --popover-fit-below, --popover-fit-above;
  }

  /* Parts. The title stays in view, and the body scrolls under it. */
  .popover > [data-slot="header"] {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: start;
    gap: 1rem;
  }
  .popover [data-slot="title"] {
    margin: 0;
    font-weight: 600;
    line-height: 1.375;
  }
  .popover [data-slot="close"] {
    /* Centred on the title's first line, and lined up with the edge of the content. */
    margin-block: calc((1.2rem - 2rem) / 2);
    margin-inline-end: -0.5rem;
  }
  .popover > [data-slot="body"] {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    /* Room for the focus rings of the controls at its edges, which it would clip. */
    margin: -0.25rem;
    padding: 0.25rem;
  }

  /* The arrow: a square turned 45deg, half of it past the edge that faces the trigger, in the
     popover's background and border, so forced colors mode draws its edge too. The edges inside
     the popover have no border, and it waits for the side it opened on. */
  .popover > [data-slot="arrow"] {
    position: absolute;
    box-sizing: border-box;
    width: var(--popover--arrow--size);
    height: var(--popover--arrow--size);
    border: inherit;
    background: inherit;
    translate: -50% -50%;
    rotate: 45deg;
  }
  .popover:not([data-side]) > [data-slot="arrow"] {
    display: none;
  }
  .popover[data-side="top"] > [data-slot="arrow"] {
    top: 100%;
    border-top-width: 0;
    border-left-width: 0;
  }
  .popover[data-side="bottom"] > [data-slot="arrow"] {
    top: 0;
    border-right-width: 0;
    border-bottom-width: 0;
  }
  .popover[data-side="left"] > [data-slot="arrow"] {
    left: 100%;
    border-bottom-width: 0;
    border-left-width: 0;
  }
  .popover[data-side="right"] > [data-slot="arrow"] {
    left: 0;
    border-top-width: 0;
    border-right-width: 0;
  }

  /* It fades and grows in, unless people have asked for less motion. */
  @media (prefers-reduced-motion: no-preference) {
    .popover:popover-open {
      transition:
        opacity 150ms ease-out,
        scale 150ms ease-out;
    }
    @starting-style {
      .popover:popover-open {
        opacity: 0;
        scale: 0.96;
      }
    }
  }

  /* With more contrast, the border is drawn in the text colour at full strength. */
  @media (prefers-contrast: more) {
    .popover {
      --popover--border-color: currentColor;
    }
  }

  /* Forced colors mode drops the shadow, so the border, in the system's text colour, marks its edge. */
  @media (forced-colors: active) {
    .popover {
      border-color: CanvasText;
    }
  }
}
/* Its last resorts, as tall as the side it opens on allows, less the gap on each side. The
   minimum turns down a side with less than about half the screen, so it shrinks on the
   bigger side. It only comes to this when it fits neither side, so it's taller than that
   anyway. The `below` and `above` ones are for `start` and `end`. */
@position-try --popover-fit {
  max-height: calc(100% - 2 * var(--popover--offset));
  min-height: calc(50dvh - 3rem);
}
@position-try --popover-fit-below {
  position-area: bottom;
  max-height: calc(100% - 2 * var(--popover--offset));
  min-height: calc(50dvh - 3rem);
}
@position-try --popover-fit-above {
  position-area: top;
  max-height: calc(100% - 2 * var(--popover--offset));
  min-height: calc(50dvh - 3rem);
}
</style>

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 focusTooltip
Show a few options, a short form or a hint with a link, next to its buttonPopover
Show a hint people need on a touch screen tooPopover, as a toggletip
Offer a few actions behind one buttonDropdown Menu
Suggest values as people typeCombobox
Ask a question people answer before they go onDialog

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.

Sources

Copyright © 2026