Popover

Developer Experience (DX)

How developers use the Popover, opened by a Button with popovertarget, with one typed API and an open state that follows every open and close.

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", 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 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--width sets 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.

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
arrowbooleantrueDraws an arrow that points at the trigger. Turn it off with false
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.

DX Checklist

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

FAQ

Copyright © 2026