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
- Trigger — The Button that opens it, with
popovertarget. It's yours, and it keeps its own label. - Panel — The popover itself, a
<div>in the top layer while it's open, which holds every other part. - Title — A few words on what it holds, like "Filters". It names the popover.
- Close button — A Button, named "Close", for people who can't press
Escape, like on a phone. - Body — Optional. The content, like a few options, a short form or a hint, which scrolls when it doesn't fit.
- Offset — The gap between the trigger and the panel.
- Arrow — On 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.
Top
It opens above the button.
Bottom
It opens below the button.
Start
It opens before the button.
End
It opens after the button.
<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.
Every popover has an arrow by default, as every tooltip does. It sits on the edge that faces the trigger and points at the trigger's centre, or as close as the rounded corners allow, and moves to the other edge when the popover flips. It shows at a glance which button a popover belongs to, which matters most where several sit close together, as the Tooltip's Placement explains. Where only one trigger could be meant, you can turn it off by setting arrow to false, and set its size with --popover--arrow--size.
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:
Filters
<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,
ghostButton named "Close", whichcloseLabeltranslates.Escapeand 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. Its arrow matters most here, since the button it points at is small and sits in a line of text:
Billing cycles
You're billed on the day you signed up, every month. How billing works
<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">
<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:
| State | Selector | Recommended look |
|---|---|---|
| Closed | [data-state="closed"] | Hidden, by the browser's own styles, but still in the page |
| Open | [data-state="open"], and :popover-open | Beside its trigger, in the top layer |
| Opening | @starting-style | A 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 and background. Its width, padding, corners and shadow differ, and so does its type, with a title at the text's own size, 14px rather than 16px, and an xs close button rather than an sm one. It's 15rem wide, 240px at the default text size, so every popover is the same size whatever it holds, and there's no backdrop behind it 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:
| Variable | Default | What it sets |
|---|---|---|
--popover--width | 15rem | How wide it is, and never wider than the screen less 32px |
--popover--padding | 1rem | The space inside the panel |
--popover--gap | 0.75rem | The space between the header and the body |
--popover--radius | 0.5rem | The corners |
--popover--border-color | color-mix(in srgb, currentColor 20%, transparent) | The panel's border |
--popover--background | Canvas | The panel's background |
--popover--box-shadow | 0 0.25rem 1rem color-mix(in srgb, #000 15%, transparent) | The shadow that lifts it off the page |
--popover--offset | 0.5rem | The gap between the trigger and the popover |
--popover--arrow--size | 0.75rem | The 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:
import styles from "./ShareLink.module.css";
export function ShareLink() {
return (
<Popover id="share-link" title="Share link" className={styles.wide}>
…
</Popover>
);
}
.wide {
--popover--width: 20rem;
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
popover/contrast | Must | Component | The title and the content have a contrast of 4.5:1 against the panel | WCAG 1.4.3 (AA) | axe color-contrast, contrast checker |
popover/edge | Should | Component | A border and a shadow set the panel apart from the page behind it | Open Components | Review |
popover/top-layer | Must | Component | It opens in the top layer, so no overflow or z-index around its trigger clips or covers it, above an open Dialog too | HTML | Visual regression test |
popover/flips | Must | Component | It 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 positioning | Open Components | Visual regression test |
popover/fallback-position | Should | Component | Browsers without CSS anchor positioning get the same side from a script and stay inside the viewport, although a tall popover can cover its trigger there | Open Components | Unit test, visual regression test |
popover/points-at-trigger | Must | Component | An arrow, when there is one, sits on the edge facing the trigger and points at its centre, after a flip too | Open Components | Unit test, visual regression test |
popover/fits-screen | Must | Component | It'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 fit | WCAG 1.4.10 (AA) | Emulation |
popover/resizable | Must | Component | It's sized in rem, so it grows with its text, and its title wraps rather than getting cut off | Open Components, beyond WCAG 1.4.4, 1.4.12 (AA) | 200% zoom |
popover/reduced-motion | Should | Component | It only animates as it opens when prefers-reduced-motion is no-preference | WCAG 2.3.3 (AAA) | Emulation |
popover/forced-colors | Must | Component | The panel's border stays visible in forced colours mode, which drops its shadow | Open Components | Emulation |
popover/more-contrast | Should | Component | The panel's border is drawn at full strength when prefers-contrast: more is set | Open Components | Emulation |
popover/follows-direction | Should | Component | start and end follow the reading direction, and its close button mirrors in right-to-left languages | Open Components | Unit test, review |
FAQ
position-area and position-try-fallbacks, is in Chrome and Edge 129, Firefox 147 and Safari 26, according to MDN's browser compatibility data, and the reference implementation places the popover with a script where it isn't. ToggleEvent.source, which says which button opened it, has been in every browser since 2026, and the Popover looks for its trigger with popovertarget where it isn't.Overview
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.
User Experience
How a popover behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.