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:
| Name | Kind | What it means |
|---|---|---|
open | Prop | Whether it's showing, for anything that opens on top of the page |
title | Prop | The text that names it, as on the Dialog |
placement | Prop | The side of the trigger it opens on: top, bottom, start or end, as on the Tooltip |
arrow | Prop | Draws an arrow that points at the trigger, as on the Tooltip |
closeLabel | Prop | The close button's label, for other languages, as on the Dialog |
close | Event | It closed, and why |
We recommend placement="bottom", which differs from the Tooltip's top, as Placement explains, and an arrow, as on the Tooltip. The Popover also leaves a few things out, on purpose:
- There's no
triggerslot. The button that opens a popover is a Button of yours, which points at it withpopovertarget, so it works before any script loads, and keeps its own label and its own place on the page. - There's no
modalprop. A popover that blocks the page is a Dialog. - There's no
variant,colororsize. A popover has no intent of its own, its content says what it's about, and--popover--widthsets how wide it is. - 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.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Whether it's open. It follows every open and close, so bind it both ways |
id | string | Required. Its trigger's popovertarget points to it, and its title's ID starts with it | |
title | string | Required. The text that names the popover | |
placement | "top" | "bottom" | "start" | "end" | "bottom" | The side of the trigger it opens on, before it flips |
arrow | boolean | true | Draws an arrow that points at the trigger. Turn it off with false |
closeLabel | string | "Close" | The close button's label, for other languages |
| Event | Payload | Description |
|---|---|---|
close | "escape" | "outside" | "close-button" | "trigger" | "closed" | Fires once it has closed, with why, unless your code closed it with open |
toggle | ToggleEvent | The 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.
| Slot | Description |
|---|---|
default | The 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
popovertargetandaria-haspopup="dialog", and the Popover keeps itsaria-expandedin 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
Escapebefore it does. - A Dialog around a popover stays open when
Escapecloses 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
placementnames and flipping, closing onEscapeand 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:
| State | How you control it |
|---|---|
| Open | Its trigger, or open, which follows every open and close, so bind it both ways |
| Closing | close, with why, once it has closed |
| Initial focus | data-autofocus on the control to start on |
| Focus after | Back to the trigger on its own, or anywhere you move it once it's closed |
| Placement | placement, before it flips, and arrow |
| Look | Its 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:
New label
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.
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
popover/shared-vocabulary | Must | Component | It uses the shared names: open, title (popoverTitle in Angular), placement, arrow, closeLabel and close (closeRequest in Angular) | Open Components | Review |
popover/typed | Must | Component | Props, events and slots are typed, with unions rather than string, and id and title are required | Open Components | Type check |
popover/controllable | Must | Component | open opens and closes it, and follows every other open and close, each of which fires close with why | Open Components | Unit test |
popover/root-attributes | Must | Component | Attributes, classes and listeners land on the popover element | Open Components | Unit test |
FAQ
popover="auto", a press outside and Escape close it before any script hears about them, which is what makes light dismiss work before your JavaScript loads. So close says what already happened, with why, and open follows on its own. Keep what people could lose out of a popover, or put it in a Dialog, which can ask them first.popovertarget opens and closes it, and the browser closes it on Escape and on a press outside, all in plain HTML, and the browser gives the button an expanded state in the accessibility tree (HTML-AAM). What waits for the script is the trigger's aria-expanded, and so its Expanded look, moving focus in, unless a control in it has autofocus, the close button, placing it without CSS anchor positioning and Escape stopping at a popover inside a Dialog.