Designer view
Popover
A non-modal floating surface anchored to a trigger element, used to surface contextual content the user reads or acts on without leaving the underlying view. Distinct from Modal (always centred and blocking), Drawer (edge-anchored, may be modal), and Tooltip (non-interactive, hover-driven, descriptive only). Popover content is interactive — buttons, forms, lists — and the user may tab into it.
Also called Floating panel
When to use
Use
When the user needs interactive contextual content anchored to a specific trigger — filter pickers, inline edit forms, contextual help with actions, action menus, color pickers. The user reads or acts on the content without leaving the underlying view; the popover dismisses on Escape or outside-click without ceremony.
Avoid
For blocking decisions or destructive confirmations — that is `Modal` (or `Modal` with `variant: alertdialog`). For edge-anchored content alongside the underlying view — that is `Drawer`. For non-interactive descriptive hover-text — that is `Tooltip`. For navigation menus with multiple destinations — that is `MenuButton` plus a list of `Link`s, although menu-style popovers with `aria-haspopup="menu"` are a legitimate adjacent pattern.
Versus related
- modal
`Modal` is always centred and always blocking (focus trap + inert siblings). `Popover` is anchored to a trigger and non-modal (focus may leave). Modal severs the spatial relationship to the underlying view; Popover preserves it.
- drawer
`Drawer` is anchored to a *viewport edge*; `Popover` is anchored to a *trigger element*. Drawer can be modal or non-modal; Popover is always non-modal. Drawer is for substantial content that accompanies the page; Popover is for contextual content tied to a specific trigger.
- tooltip
`Tooltip` is non-interactive (hover or focus reveals descriptive text). `Popover` is interactive (the user may tab into the body and click controls). Tooltip dismisses on blur or pointer-leave automatically; Popover dismisses on explicit user action (Escape, outside-click).
- menu-button
`MenuButton` opens a `role="menu"` with menu-keyboard semantics (ArrowKeys navigate, typeahead, single-action commit). `Popover` opens a `role="dialog"` containing arbitrary interactive content. The aria-haspopup value differentiates: "menu" for MenuButton, "dialog" for Popover.
- menu
`Menu` is a `role="menu"` surface hosting a list of commands with the APG menu keyboard model (arrow keys, typeahead, roving-tabindex; Tab closes / exits). `Popover` is a `role="dialog"` surface hosting arbitrary interactive content (the user may Tab through controls). The role-distinction drives the keyboard contract; choose Popover for content panels (forms, rich descriptions, multi-element compositions) and Menu for command lists (single-action picks).
Popover is a non-modal floating surface anchored to a trigger — for menu-like option lists, form-fragments, info-cards, and rich tooltips that need interaction. Tab moves through the popover content and out into the page; light-dismiss (Escape, outside-click, focus-outside) handles closure. The reference covers the auto-flip behaviour on viewport collision, the dismiss-reason vocabulary, the trigger ARIA contract with aria-haspopup and aria-expanded, and the portal-and-stacking primitive that the native HTML popover attribute now provides under the hood.
Implementations
How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.
Popover / PopoverButton / PopoverPanel / PopoverBackdrop / PopoverGroup / CloseButton import { Popover, PopoverButton, PopoverPanel,} from '@headlessui/react';
export function FilterPopover() { return ( <Popover className="relative"> <PopoverButton className="rounded border px-3 py-1.5 data-[open]:bg-gray-100"> Filters </PopoverButton> <PopoverPanel anchor="bottom start" transition className={[ 'rounded border bg-white shadow-lg p-4 w-64', 'data-[closed]:opacity-0 data-[enter]:duration-150 data-[leave]:duration-100', 'transition', ].join(' ')} > {({ close }) => ( <div> <p className="text-sm font-medium mb-2">Filter by status</p> <label className="flex items-center gap-2 text-sm"> <input type="checkbox" name="active" /> Active </label> <label className="flex items-center gap-2 text-sm"> <input type="checkbox" name="archived" /> Archived </label> <button onClick={() => close()} className="mt-3 w-full rounded bg-blue-600 text-white px-3 py-1.5 text-sm" > Apply </button> </div> )} </PopoverPanel> </Popover> );}Divergence
| From | Type | → To | Rationale |
|---|---|---|---|
anatomy[trigger] | renamed | PopoverButton | The canonical `trigger` slot is described as consumer-provided — the Popover component wires ARIA onto a user-supplied element. Headless UI ships `PopoverButton` as a first-class component that renders a `<button>` by default and wires `aria-expanded` and `aria-controls` automatically. The practical contract is equivalent (the button toggles the panel) but the implementation is a library primitive rather than a bare consumer element. Source: https://headlessui.com/react/popover (PopoverButton props, 2026-05-31). |
anatomy[container] | renamed | PopoverPanel | The canonical `container` slot is the bounded floating surface. Headless UI names this `PopoverPanel`. It renders a `<div>` by default with no explicit ARIA role — no `role="dialog"` is applied. The canonical recommends `role="dialog"` with `aria-labelledby` for interactive popovers; consumers must add this manually. Source: https://headlessui.com/react/popover (PopoverPanel props, 2026-05-31). |
anatomy[arrow] | omitted | — | Headless UI ships no arrow primitive for Popover. The canonical `arrow` slot is a decorative tip indicator pointing from the panel back to the trigger, whose position auto-tracks via the positioning library. Headless UI's `anchor` API (backed by Floating UI) does not expose an arrow/middleware component for Popover; consumers must author a CSS pseudo-element or absolute-positioned child and derive arrow placement manually from the `anchor.to` side. Source: https://headlessui.com/react/popover (PopoverPanel anchor prop, 2026-05-31). |
anatomy[header] | omitted | — | Headless UI provides no structured header slot. All content inside `PopoverPanel` is free children; the library enforces no header/body/footer subdivision. Consumers who want a labelled header must author it as plain JSX. Source: https://headlessui.com/react/popover (PopoverPanel, 2026-05-31). |
anatomy[title] | omitted | — | There is no title slot or component. `PopoverPanel` carries no `aria-labelledby` pointing to a title element; the panel itself has no explicit ARIA role that would require a label. Consumers who apply `role="dialog"` manually must also wire their own `aria-labelledby` to a heading inside the panel. Source: https://headlessui.com/react/popover (PopoverPanel, 2026-05-31). |
anatomy[body] | omitted | — | Headless UI has no `body` slot primitive. Content is placed directly as children of `PopoverPanel` (or via a render-function child for access to the `close` render prop). The body-as-named-slot contract from the canonical has no counterpart; there is no scroll container or semantic wrapper. Source: https://headlessui.com/react/popover (PopoverPanel, 2026-05-31). |
anatomy[close-button] | renamed | CloseButton | The canonical `close-button` sub-anatomy slot maps to Headless UI's `CloseButton` component, which closes the nearest ancestor `Popover` when clicked. The library also exposes a `useClose()` hook for the same purpose from deeply nested components. Alternatively, the `close` render prop from `PopoverPanel`'s children function can be called directly. Source: https://headlessui.com/react/popover (CloseButton, useClose, 2026-05-31). |
anatomy[close-label] | omitted | — | The canonical `close-label` sub-anatomy slot is a visually-hidden `<span>` that provides an accessible name for the close button. `CloseButton` in Headless UI renders a plain `<button>` with no enforced accessible-name strategy; consumers must supply the label themselves as button text or `aria-label`. Source: https://headlessui.com/react/popover (CloseButton props, 2026-05-31). |
axes.variants[standard] | omitted | — | Headless UI Popover has no variant system. The `standard` / `tip` distinction in the canonical maps to whether an arrow is rendered; since Headless UI has no arrow primitive, there is also no variant to select it. Consumers who want a tip-arrow popover must author the arrow slot manually regardless. Source: https://headlessui.com/react/popover (2026-05-31). |
axes.variants[tip] | omitted | — | No `tip` variant. Headless UI has no arrow component for Popover, so neither `standard` nor `tip` variants exist as API concepts. See `axes.variants[standard]` omission above. Source: https://headlessui.com/react/popover (2026-05-31). |
axes.properties[side] | reshaped | anchor prop on PopoverPanel — string form: "bottom", "top", "left", "right", "bottom start", "bottom end", etc. | The canonical treats `side` (top/right/bottom/left) and `align` (start/center/end) as separate props. Headless UI collapses both into a single `anchor` string or object on `PopoverPanel`. The string form `"bottom start"` encodes side+align together; the object form `{ to, gap, offset, padding }` separates positioning from spacing. There is no discrete `side` prop. Source: https://headlessui.com/react/popover (PopoverPanel anchor prop, 2026-05-31). |
axes.properties[align] | reshaped | alignment suffix in anchor string — "bottom start" / "bottom end" / "top start" etc. | The canonical `align` (start/center/end) is encoded as a suffix in Headless UI's `anchor` string. `"bottom"` means center-aligned; `"bottom start"` means start-aligned. There is no standalone `align` prop. Source: https://headlessui.com/react/popover (PopoverPanel anchor prop, 2026-05-31). |
axes.properties[dismissible] | omitted | — | There is no `dismissible` prop. Headless UI always closes `PopoverPanel` on outside-click, Escape, and tab-focus-loss — these light-dismiss handlers are unconditional. The canonical `dismissible: false` variant (which disables outside-click for popovers hosting in-flight commits) has no equivalent; consumers cannot suppress light-dismiss without reimplementing the close logic. Source: https://headlessui.com/react/popover (closing behaviour, 2026-05-31). |
axes.properties[flip] | omitted | — | No discrete `flip` prop. Headless UI's `anchor` positioning (backed by Floating UI) performs auto-flip implicitly when the authored side overflows the viewport — consumers cannot opt out of flip nor inspect the resolved side/align after a flip. The `anchor.padding` sub-property controls the viewport clearance threshold that triggers flip, but there is no boolean to disable auto-flip. Source: https://headlessui.com/react/popover (PopoverPanel anchor prop, 2026-05-31). |
events[openChange] | reshaped | open render prop on Popover and PopoverPanel children — no callback prop | The canonical `openChange` is a `(open: boolean) => void` callback that fires after the transition settles on both edges. Headless UI exposes no `onOpenChange`, `onOpen`, or `onClose` callback on `Popover`. The open state is available only as the `open` boolean render prop on `Popover` children and on `PopoverPanel` children (via the `{ open, close }` render-function). Consumers who need to react to state changes must derive them from the render prop (e.g. via a `useEffect` watching a controlled state). Source: https://headlessui.com/react/popover (Popover render props, 2026-05-31). |
events[dismiss] | omitted | — | Headless UI exposes no dismiss event and no reason discrimination (escape / outsideClick / focusOutside / closeButton). Outside-click, Escape, and tab-focus-loss all close the panel silently. Consumers who need reason-discrimination must attach their own `onKeyDown` (Escape), `onPointerDownOutside` emulation, or `onBlur` capture handlers separately and synthesise the reason. Source: https://headlessui.com/react/popover (closing behaviour, 2026-05-31). |
events[positionChange] | omitted | — | No `positionChange` event or equivalent. After auto-flip, the resolved placement is not surfaced via any callback or exposed attribute. The `data-open` attribute on `PopoverPanel` only reflects open/closed state, not the actual rendered side/align. Consumers cannot programmatically detect whether a flip occurred. Source: https://headlessui.com/react/popover (PopoverPanel data attributes, 2026-05-31). |
anatomy[container] | extended | + `PopoverBackdrop` — an optional overlay element rendered behind `PopoverPanel`. Accepts `as` (default `div`) and `transition` props. Exposes `data-open`, `data-closed`, `data-enter`, `data-leave` attributes for CSS-driven animation. Consumers position it as `fixed inset-0` to create a click-to-dismiss scrim. The canonical anatomy has no backdrop slot for Popover (backdrop belongs to Modal and modal Drawer). | Headless UI designed `PopoverBackdrop` for navigation mega-menu patterns where a translucent overlay dims the page while the popover is open — a pattern the canonical anatomy does not cover. Using a full backdrop on a non-modal popover can blur the line between Popover and Dialog; the canonical's `contracts.nonNegotiable` notes that installing modal behaviour on a Popover is an anti-pattern. Source: https://headlessui.com/react/popover (PopoverBackdrop, 2026-05-31). |
anatomy[trigger] | extended | + `PopoverGroup` — a wrapper that coordinates sibling `Popover` elements. Within a group, tabbing from one popover's button to the next keeps all panels open; tabbing out of the group closes all open panels. This matches the navigation mega-menu pattern (e.g. a top nav with multiple category popovers). Accepts an `as` prop (default `div`). | The canonical anatomy covers a single Popover anchored to a single trigger. Grouped sibling coordination is a legitimate extension for navigation patterns, where users expect to switch between open popovers by tabbing rather than having to close one before opening another. Source: https://headlessui.com/react/popover (PopoverGroup, 2026-05-31). |
anatomy[container] | extended | + `anchor` prop on `PopoverPanel` provides first-party Floating UI-backed positioning. String form accepts `"top"`, `"bottom"`, `"left"`, `"right"` with optional `"start"` / `"end"` alignment modifiers. Object form accepts `{ to, gap, offset, padding }`. When `anchor` is set, `portal` auto-enables. CSS custom properties `--button-width`, `--anchor-gap`, `--anchor-offset`, `--anchor-padding` are injected for consumer sizing. | The canonical documents floating positioning as a concern of the positioning library (Floating UI / Popper / CSS anchor positioning) but prescribes no specific prop API. Headless UI ships first-party positioning backed by Floating UI to remove the need for consumers to wire a separate library, collapsing `side` + `align` + sideOffset into the single `anchor` string. Source: https://headlessui.com/react/popover (PopoverPanel anchor prop, 2026-05-31). |
anatomy[container] | extended | + `focus: boolean` prop on `PopoverPanel` (default `false`). When `true`, Headless UI moves focus to the first focusable element inside the panel on open, and closes the panel if focus leaves. This opt-in auto-focus matches the canonical "form-style popover" behaviour but is a separate prop rather than a consequence of the popover's semantic role. | The canonical anatomy notes that focus may move into the popover for form-style use-cases (consumer-configured). Headless UI surfaces this as a first-party prop rather than relying on consumers to manage `autoFocus` on children or call `focus()` imperatively. Without `focus={true}`, focus stays on the trigger after open, matching the canonical default for menu-style and info-style popovers. Source: https://headlessui.com/react/popover (PopoverPanel focus prop, 2026-05-31). |
anatomy[container] | extended | + `modal: boolean` prop on `PopoverPanel` (default `false`). When `true`, scroll-locking is enabled on the document body while the panel is open. Does not install a focus trap — focus can still leave the panel. This is distinct from the `focus` prop and from the Modal/Drawer `modal` semantics. | The canonical states explicitly that Popover is non-modal and must not install a focus trap. The Headless UI `modal` prop is narrower — it only locks scroll, not focus — so it does not violate the canonical non-modal contract. However, naming a prop `modal` on a canonically non-modal component risks confusion; consumers may expect full modality semantics. The canonical's `contracts.nonNegotiable` warns against focus-trapping; scroll-locking alone is an acceptable trade-off for bottom-sheet-style popovers on mobile. Source: https://headlessui.com/react/popover (PopoverPanel modal prop, 2026-05-31). |
anatomy[close-button] | extended | + `useClose()` React hook — returns the `close` function for the nearest ancestor `Popover`. Allows deeply nested components (e.g. an item inside a virtualised list within the panel) to imperatively close the popover without prop-drilling the `close` render prop. Accepts an optional ref argument to redirect focus after close. | The canonical anatomy exposes close behaviour via the close-button slot only. Headless UI's hook form is an ergonomic extension for consumers building panels with complex nested content that shouldn't take a close callback as a prop. Source: https://headlessui.com/react/popover (useClose hook, 2026-05-31). |
anatomy[trigger] | omitted | — | The canonical `trigger` slot requires `aria-haspopup="dialog"` (or `"menu"` for menu-style popovers). Headless UI's `PopoverButton` sets `aria-expanded` and `aria-controls` but does NOT set `aria-haspopup`. Screen reader users therefore receive no hint about the nature of the revealed surface before activating the button. This is a known gap in Headless UI's Popover implementation; consumers must add `aria-haspopup="dialog"` manually via the `as` prop or by passing it directly to `PopoverButton`. Source: github.com/tailwindlabs/headlessui popover.tsx (aria attributes on PopoverButton, fetched 2026-05-31); https://headlessui.com/react/popover (PopoverButton props, 2026-05-31). |
Why this audit reads the way it does
Headless UI React Popover is an unstyled disclosure-style floating panel primitive. It honours the core non-modal Popover contract (outside-click, Escape, and tab-focus-loss all close the panel; no focus trap by default) but diverges from the canonical anatomy in several consistent ways aligned with the library's design philosophy. The divergences cluster into four groups: 1. Flattened anatomy — Headless UI provides no structured header/body/footer subdivision, no title slot, no arrow primitive, and no close-label slot. Content is free children of PopoverPanel. Consumers who want labelled, sectioned panels with visible close affordances must author all structure manually, including ARIA relationships (role="dialog", aria-labelledby). 2. Collapsed positioning API — The canonical separate `side` and `align` props are collapsed into Headless UI's single `anchor` string/object. The `flip` and `dismissible` boolean props have no equivalents; auto-flip is unconditional and light-dismiss cannot be suppressed. 3. Event model — No callback API for state changes. The `open` boolean is available only as a render prop; there is no `onOpenChange`, and no dismiss event with reason discrimination. Consumers must derive state changes from the render prop or controlled state. 4. Extensions beyond the canonical — `PopoverGroup` coordinates sibling popovers for navigation menus; `PopoverBackdrop` adds an optional scrim; `focus` prop auto-moves focus into the panel on open; `modal` prop adds scroll-locking (not a focus trap); `useClose` hook enables imperative close from deeply nested children. None of these have canonical equivalents. The `aria-haspopup` omission on `PopoverButton` is the most significant accessibility gap vs. the canonical contract; consumers must add it manually.
Popover import * as Popover from '@radix-ui/react-popover';import { Cross2Icon } from '@radix-ui/react-icons';
<Popover.Root open={open} onOpenChange={setOpen}> <Popover.Trigger asChild> <Button>Open settings</Button> </Popover.Trigger>
<Popover.Portal> <Popover.Content side="bottom" align="start" sideOffset={8} avoidCollisions onOpenAutoFocus={(e) => e.preventDefault()} className="PopoverContent" > {/* body content — forms, prose, lists */} <form> <label> Width <input type="number" defaultValue={300} /> </label> </form>
<Popover.Close asChild> <button aria-label="Close"> <Cross2Icon /> </button> </Popover.Close>
<Popover.Arrow className="PopoverArrow" /> </Popover.Content> </Popover.Portal></Popover.Root>Divergence
| From | Type | → To | Rationale |
|---|---|---|---|
anatomy[trigger] | renamed | Popover.Trigger | Direct match. Radix renders the trigger as `Popover.Trigger` and automatically wires `aria-haspopup`, `aria-expanded`, and `aria-controls` to `Popover.Content`. The `asChild` prop delegates rendering to the consumer's own button (the canonical pattern); without `asChild`, Radix renders a plain `<button>`. Source: https://www.radix-ui.com/primitives/docs/components/popover#trigger (verified 2026-05-31, @radix-ui/react-popover 1.1.15). |
anatomy[container] | reshaped | Popover.Root + Popover.Portal + Popover.Content | The canonical container is one slot that owns both the floating surface and the ARIA role. Radix splits this across three components: Root owns controlled-state (`open`, `onOpenChange`) and the `modal` escape hatch; Portal owns the DOM relocation into `document.body` (or a custom container); Content owns the rendered panel, `role="dialog"` (implicit via the Dialog WAI-ARIA pattern), and the positioning props (`side`, `align`, `sideOffset`, `avoidCollisions`). Consumers composing the canonical anatomy use all three together. Source: https://www.radix-ui.com/primitives/docs/components/popover (verified 2026-05-31). |
anatomy[header] | omitted | — | Radix Popover has no header sub-component. Unlike `@radix-ui/react-dialog` which ships `Dialog.Title` (wired to `aria-labelledby`), Radix Popover ships no equivalent `Popover.Title`. A title and header layout are entirely a consumer responsibility: the consumer renders a heading element inside `Popover.Content` and wires `aria-labelledby` manually if needed. The canonical header-bar region (title + close button row) maps to an unsupported consumer-composed `<div>`. Source: https://www.radix-ui.com/primitives/docs/components/popover (verified 2026-05-31; no Popover.Title or Popover.Header component listed). |
anatomy[title] | omitted | — | Radix Popover exports no `Popover.Title` sub-component. The Dialog package ships `Dialog.Title` (which auto-wires `aria-labelledby`), but Popover does not replicate it. Consumers who need a labelled popover must render their own heading and call `aria-labelledby` manually on `Popover.Content`. Source: https://www.radix-ui.com/primitives/docs/components/popover (verified 2026-05-31). |
anatomy[body] | reshaped | free children of Popover.Content | The canonical body is a discrete slot with its own semantic and token budget. Radix has no `Popover.Body` or `Popover.Description` for Popover (unlike `Dialog.Description` in the Dialog package). All body content is free children of `Popover.Content`; no semantic wrapper, no `aria-describedby` wiring, and no padding or typography tokens are applied by the library. Consumers own the layout and accessible-description relationship entirely. Source: https://www.radix-ui.com/primitives/docs/components/popover#content (verified 2026-05-31). |
anatomy[close-button] | renamed | Popover.Close | Functional match. `Popover.Close` renders a `<button>` that closes the popover on click. The `asChild` prop delegates to the consumer's own button (for icon-button variants with custom accessible names). Like `Dialog.Close`, the component provides no default accessible name — the consumer must supply `aria-label` or a visually-hidden `<span>` inside their button. Source: https://www.radix-ui.com/primitives/docs/components/popover#close (verified 2026-05-31). |
anatomy[close-label] | omitted | — | Radix Popover ships no managed accessible-name slot for the close button. The canonical `close-label` is a visually-hidden `<span>` whose text ("Close" or "Close popover") is surfaced to assistive technology. With Radix, the consumer is responsible for placing the hidden span or using `aria-label` on the button they pass via `asChild`; the library makes no provision for it. Source: https://www.radix-ui.com/primitives/docs/components/popover#close (verified 2026-05-31). |
anatomy[arrow] | renamed | Popover.Arrow | Near-direct match. `Popover.Arrow` renders an SVG polygon pointing toward the trigger and tracks the trigger's bounding box automatically via Floating UI internals. Unlike the canonical arrow slot (which is agnostic to dimensions), Radix Arrow accepts explicit `width` (default 10) and `height` (default 5) props and must be placed inside `Popover.Content` — it is not in `Popover.Portal` directly. The `asChild` prop allows replacing the SVG with a custom element. Source: https://www.radix-ui.com/primitives/docs/components/popover#arrow (verified 2026-05-31). |
axes.variants[tip] | omitted | — | Radix has no `tip` variant. The canonical `tip` variant always renders the arrow; the `standard` variant never renders it. In Radix, the arrow is an unconditional optional sub-component (`Popover.Arrow`) — the consumer chooses to include it or not regardless of any variant prop. There is no variant enum on Radix Popover that gates the arrow's presence. Source: https://www.radix-ui.com/primitives/docs/components/popover (verified 2026-05-31). |
axes.properties[dismissible] | reshaped | onPointerDownOutside / onEscapeKeyDown / onFocusOutside callbacks on Popover.Content (call event.preventDefault() to suppress) | The canonical `dismissible: boolean` is a single switch covering outside-click, Escape, and focus-outside dismissal paths. Radix does not expose a unified flag — `Popover.Content` receives three separate callbacks (`onPointerDownOutside`, `onEscapeKeyDown`, `onFocusOutside`) and a consumer suppresses any dismissal path by calling `event.preventDefault()` inside the relevant handler. Recovering the canonical boolean requires wiring all three. Source: https://www.radix-ui.com/primitives/docs/components/popover#content (verified 2026-05-31). |
axes.properties[flip] | renamed | avoidCollisions (boolean, default true) on Popover.Content | The canonical `flip: boolean` controls whether the popover flips to the opposite side on viewport collision. Radix names this `avoidCollisions` (default `true`) on `Popover.Content`. The semantics are identical. Radix also exposes `collisionBoundary` and `collisionPadding` props for finer control, and the `sticky` prop for shift-without-flip behaviour — these are extensions beyond the canonical boolean. Source: https://www.radix-ui.com/primitives/docs/components/popover#content (verified 2026-05-31). |
events[openChange] | renamed | onOpenChange (Popover.Root prop) | One-to-one match. `onOpenChange(open: boolean)` on `Popover.Root` fires when the popover opens or closes. The canonical event payload contract holds. Source: https://www.radix-ui.com/primitives/docs/components/popover#root (verified 2026-05-31). |
events[dismiss] | reshaped | onPointerDownOutside / onEscapeKeyDown / onFocusOutside callbacks on Popover.Content; no unified reason enum | The canonical `dismiss` event carries `{ reason: 'escape' | 'outsideClick' | 'closeButton' | 'focusOutside' }`. Radix does not surface a unified dismiss event — escape fires `onEscapeKeyDown`, pointer-down-outside fires `onPointerDownOutside`, focus-outside fires `onFocusOutside`, and close-button click is a regular click event on the consumer's `Popover.Close` button. Recovering the canonical reason vocabulary requires listening on three separate `Popover.Content` callbacks and synthesising the union at the consumer layer. Source: https://www.radix-ui.com/primitives/docs/components/popover#content (verified 2026-05-31). |
events[positionChange] | reshaped | data-side and data-align attributes on Popover.Content (reflect actual rendered placement after flip/shift) | The canonical `positionChange` event emits `{ side, align }` reflecting the actual rendered placement after auto-flip and shift. Radix does not fire a discrete event — instead, `Popover.Content` exposes `[data-side]` and `[data-align]` DOM attributes that update reactively when the placement changes (e.g. from `bottom` to `top` after flip). Consumers needing to react programmatically must observe these attribute changes via `MutationObserver` or use Floating UI's `onFloatingChange` at a lower layer. Source: https://www.radix-ui.com/primitives/docs/components/popover#content (verified 2026-05-31). |
anatomy[container] | extended | + Popover.Anchor — an optional separate positioning reference element that decouples the floating-anchor from the toggle trigger | The canonical anatomy has no concept of a separate anchor element — the trigger always serves as both the toggle activator and the positioning reference. Radix adds `Popover.Anchor` as an opt-in sub-component that overrides the positioning reference without affecting the toggle behaviour. This is useful when the popover should be anchored to a larger container (e.g. a table row) while being toggled by a button inside that row. Source: https://www.radix-ui.com/primitives/docs/components/popover#anchor (verified 2026-05-31). |
axes.properties[dismissible] | extended | + modal (boolean, default false) on Popover.Root — enables modal behaviour (focus trap, pointer-events blocked on rest of page) | The canonical Popover is always non-modal; there is no `modal` axis. Radix adds a `modal` prop on `Popover.Root` (default `false`) that, when `true`, installs a focus trap and blocks pointer events on the rest of the page — turning the Popover into a semantically undeclared modal. This is a canonical anti-pattern (the canonical contracts section forbids installing a focus trap on a Popover), but Radix exposes the escape hatch. Consumers should use `@radix-ui/react-dialog` instead when modality is required. Source: https://www.radix-ui.com/primitives/docs/components/popover#root (verified 2026-05-31). |
motion.durations | reshaped | data-state='open' | 'closed' CSS attribute selectors on Popover.Content; plus CSS custom properties --radix-popover-content-transform-origin for enter/exit animations | Radix does not ship motion duration tokens. Consumers write CSS transitions on `[data-state='open']` and `[data-state='closed']` on `Popover.Content` with their own duration values. The `--radix-popover-content-transform-origin` CSS custom property is exposed so that scale-from-anchor animations originate at the trigger's bounding box. Canonical `motion.durations.open` / `close` / `flip` are achievable but values are entirely consumer-side. Source: https://www.radix-ui.com/primitives/docs/components/popover#content (verified 2026-05-31). |
motion.reducedMotionFallback | omitted | — | Radix does not implement a reduced-motion fallback. The `data-state` attribute still toggles between `open` and `closed` — but no animation is suppressed automatically. Consumers must apply `@media (prefers-reduced-motion: reduce)` in their CSS to satisfy the canonical `instant` fallback requirement. Source: https://www.radix-ui.com/primitives/docs/components/popover (verified 2026-05-31). |
Why this audit reads the way it does
Radix Popover is a low-level, unstyled floating primitive that covers the canonical Popover's portal, positioning, and light-dismiss contract well, but deliberately omits design-system layering. The most significant divergences: 1. The canonical container is reshaped into Root + Portal + Content, following the same pattern as Radix Dialog. Root owns state, Portal owns DOM relocation, Content owns the rendered surface and positioning props. 2. Radix Popover ships no title sub-component (contrast with Dialog.Title). The canonical header-bar anatomy (title + close row) is entirely consumer-composed inside Popover.Content. This is the largest accessibility gap — `aria-labelledby` on the popover panel requires manual wiring. 3. Dismiss control is reshaped from a single `dismissible: boolean` into three separate callbacks on Content, consistent with how Radix Dialog handles the same canonical property. 4. The canonical `tip` variant is not modelled — Radix treats the arrow as an optional sub-component unconditionally, not as a variant gate. 5. The `modal` prop on Root is a canonical anti-pattern escape hatch; consumers should use @radix-ui/react-dialog when modality is required.
Popover import { Button, Dialog, DialogTrigger, Heading, OverlayArrow, Popover,} from 'react-aria-components';
// Standard popover with Dialog for full accessibility contract<DialogTrigger> <Button>Open settings</Button> <Popover placement="bottom start" offset={8} shouldFlip> <OverlayArrow> <svg width={12} height={12} viewBox="0 0 12 12"> <path d="M0 0 L6 6 L12 0" /> </svg> </OverlayArrow> <Dialog aria-labelledby="settings-title"> <Heading id="settings-title" slot="title">Settings</Heading> <p>Popover body content here.</p> </Dialog> </Popover></DialogTrigger>
// Without arrow (standard, no tip)<DialogTrigger> <Button>Filter</Button> <Popover placement="bottom" offset={8}> <Dialog aria-labelledby="filter-title"> <Heading id="filter-title" slot="title">Filters</Heading> <Switch defaultSelected>Wi-Fi</Switch> <Switch>Bluetooth</Switch> </Dialog> </Popover></DialogTrigger>
// Standalone with triggerRef (no DialogTrigger wrapper)function Example() { const [isOpen, setOpen] = useState(false); const triggerRef = useRef(null); return ( <> <Button ref={triggerRef} onPress={() => setOpen(true)}>Open</Button> <Popover triggerRef={triggerRef} isOpen={isOpen} onOpenChange={setOpen} placement="right" isKeyboardDismissDisabled={false} > <Dialog aria-label="Quick actions"> <p>Content here.</p> </Dialog> </Popover> </> );}Divergence
| From | Type | → To | Rationale |
|---|---|---|---|
anatomy[trigger] | reshaped | First child of <DialogTrigger> (or any element referenced via triggerRef=). DialogTrigger uses useOverlayTrigger({type:'dialog'}) internally to wire aria-haspopup="dialog", aria-expanded, and aria-controls onto the trigger element. The trigger is not a named slot — it is the first (non-Popover) child of DialogTrigger, or a standalone element when using triggerRef. | React Aria expresses the trigger relationship through the DialogTrigger compound wrapper rather than a named slot prop. The consumer places any pressable element (Button, Link, etc.) as a sibling of Popover inside DialogTrigger; the library spreads triggerProps (aria-haspopup="dialog", aria-expanded, aria-controls) automatically via useOverlayTrigger. This gives the same ARIA output as the canonical contract but through a composition boundary rather than a slot API. For standalone use, consumers pass triggerRef directly and manage isOpen/onOpenChange themselves. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31), https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Dialog.tsx (fetched 2026-05-31) |
anatomy[container] | reshaped | <Popover> renders a <div> that auto-assigns role="dialog" when no nested [role=dialog] child is detected and the popover is not isNonModal. When a <Dialog> child is present (the recommended pattern), <Dialog> carries role="dialog" as a <section> and the <Popover> div itself has no role. Accessible name comes from aria-labelledby on Dialog pointing at a nested Heading, or from aria-label on Dialog directly. | React Aria splits the positioning/overlay surface (Popover div) from the dialog semantics (Dialog section). The Popover div handles portal mounting, floating positioning via @react-aria/overlays, and light-dismiss. Dialog carries the ARIA dialog role with its accessible-name contract. This split lets the same Popover positioning primitive serve both dialog popovers and non-dialog overlays (e.g. ComboBox listbox) without baking role="dialog" unconditionally onto the container. When used without a nested Dialog the Popover div self-promotes to role="dialog" as a fallback. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31), https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Popover.tsx (fetched 2026-05-31) |
anatomy[arrow] | renamed | OverlayArrow | The canonical slot is named `arrow`; React Aria names the sub-component `OverlayArrow`. OverlayArrow renders a wrapper div (class react-aria-OverlayArrow) whose children — typically an inline SVG — auto-rotate to match the actual rendered placement (data-placement). The canonical arrow is optional and decorative; OverlayArrow matches that contract. Consumers omit OverlayArrow entirely for the standard (no-tip) variant. React Aria does not ship a built-in SVG shape; the consumer provides the SVG child. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
anatomy[header] | omitted | — | React Aria Popover ships no dedicated header-bar sub-component. The header-bar (including title + close-button slots) from the canonical header-bar sub-anatomy is entirely absent as a structured slot. Consumers compose heading and close affordances inside a <Dialog> child using arbitrary markup — a <Heading slot="title"> for the accessible name and a consumer-authored close Button. No grid-layout header container, no token bindings for header spacing, and no explicit header region are provided by the library. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
anatomy[title] | reshaped | <Heading slot="title"> inside a <Dialog> child — an arbitrary heading element wired to Dialog's aria-labelledby context via the slot="title" prop. No dedicated Title sub-component; the slot prop on Heading is a React Aria context mechanism. | The canonical title slot is a structured anatomy entry on the popover with its own figma/code/a11y metadata. React Aria instead provides a Dialog context that looks for a child element with slot="title" and automatically wires aria-labelledby on the Dialog to that element's id. The heading level is consumer-chosen (Heading renders the appropriate h1-h6). No standalone Title sub-component exists in react-aria-components for Popover. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Dialog.tsx (fetched 2026-05-31) |
anatomy[body] | omitted | — | React Aria Popover provides no dedicated body slot or sub-component. Content is arbitrary JSX children of the Popover (or of a nested Dialog). The canonical body slot carries layout metadata (row, span, padding tokens) and a semantic hint (prose-or-form-or-list); React Aria places no structural constraint on the inner content beyond whatever Dialog's children context provides. Consumers are responsible for all body layout, padding tokens, and scroll behaviour. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
anatomy[close-button] | omitted | — | React Aria ships no built-in close-button slot or sub-component for Popover. Dialog's render-prop API exposes a close() function to children, allowing consumers to wire a Button to it. No close-button is rendered by default. The canonical close-button (with visually-hidden label and Escape fallback) is entirely a consumer responsibility. Escape always dismisses the Popover unless isKeyboardDismissDisabled=true; a visible close button is optional and consumer-composed. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Dialog.tsx (fetched 2026-05-31) |
axes.variants[standard] | reshaped | No variant prop. Standard appearance (no arrow) is achieved by omitting <OverlayArrow> from the Popover children. React Aria has no variant enum. | The canonical `variant: standard | tip` axis is expressed implicitly in React Aria: the tip visual is achieved by including <OverlayArrow> as a Popover child; the standard appearance is the default when OverlayArrow is absent. There is no variant prop. This means the variant is not statically inspectable from the component API — it is a structural composition choice. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.variants[tip] | reshaped | Adding <OverlayArrow> as a child of <Popover>. OverlayArrow auto-rotates to match data-placement. No variant prop to toggle. | See rationale for variants[standard] above — React Aria collapses the tip/standard distinction to presence or absence of the OverlayArrow sub-component rather than a named variant enum value. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties[side] | reshaped | placement prop of type Placement — a richer union than the canonical four sides. Valid values include 'top' | 'bottom' | 'left' | 'right' | 'start' | 'end' plus compound forms 'bottom start' | 'bottom end' | 'top start' | 'top end' | 'left top' | 'left bottom' | 'right top' | 'right bottom' | 'start top' | 'start bottom' | 'end top' | 'end bottom'. Default is 'bottom'. | The canonical `side` prop is a simple four-value enum (top/right/bottom/left). React Aria encodes both side and alignment in a single `placement` prop using the Floating UI / @react-aria/overlays Placement type. This richer type covers the logical directions (start/end) and compound placements that specify both side and cross-axis alignment in one string. There is no separate `align` prop on Popover; alignment is expressed through the placement string or adjusted numerically via crossOffset. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties[align] | reshaped | Encoded inside the placement prop as compound values ('bottom start', 'bottom end', 'bottom' for center) or adjusted numerically via the crossOffset prop (number, default 0). | The canonical `align: start | center | end` is a separate prop. React Aria collapses it into the `placement` string (e.g. 'bottom start' = side:bottom, align:start) or delegates to `crossOffset` for pixel-level shifts. There is no standalone `align` enum prop. This is a concrete structural difference from the canonical API surface. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties[dismissible] | reshaped | Two separate props: (1) isKeyboardDismissDisabled (boolean, default false) disables Escape key dismissal only. (2) Outside-click dismissal is always active and is not exposed as a toggleable prop on Popover directly — it is handled by the overlay infrastructure automatically. | The canonical `dismissible: boolean` is a single toggle controlling all light-dismiss paths (Escape + outside-click + focus-outside). React Aria splits this: Escape can be disabled via isKeyboardDismissDisabled; outside pointer interaction is governed by isNonModal (which controls AT interaction, not pointer dismiss). There is no single prop matching the canonical `dismissible: false` semantics. Consumers needing to prevent outside-click dismissal must compose a custom overlay or use a Modal instead. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties[flip] | renamed | shouldFlip | One-to-one semantic match. The canonical `flip: boolean` and React Aria `shouldFlip: boolean` both control whether the popover auto-repositions to the opposite side when the authored placement overflows the viewport. Default is true in both cases. Only the name differs, following React Aria's 'should*' predicate prefix for boolean configuration props. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties | extended | + offset (number, default 8) — main-axis distance in pixels between the Popover and its anchor element. No canonical equivalent; the canonical container token uses spacing.compact abstractly without an explicit numeric offset prop. | React Aria exposes the floating positioning offset as a first-class numeric prop, consistent with its underlying @react-aria/overlays positioning engine. The canonical documents the default offset as 8px in layout metadata but provides no API hook to override it declaratively. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties | extended | + crossOffset (number, default 0) — cross-axis offset in pixels, adjusting the popover along the perpendicular axis to the placement side. Provides pixel-level align adjustment beyond what compound placement strings express. | No canonical equivalent. React Aria provides crossOffset as an escape hatch for cases where compound placement values ('bottom start') do not achieve the required visual alignment — for example when a pixel nudge is needed without changing the logical alignment. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties | extended | + containerPadding (number, default 12) — minimum distance in pixels between the Popover and the viewport edge when computing flip and shift adjustments. | No canonical equivalent. React Aria exposes this to prevent the popover from touching the viewport boundary after auto-flip or shift, which is important for readability on small screens. The canonical documents viewport-collision flip behaviour but not a numeric boundary-clearance API. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties | extended | + isNonModal (boolean) — when true, elements outside the Popover may be interacted with by assistive technologies. When false (default for dialog popovers), content outside is hidden from AT while the Popover is open. Recommended only for combobox-style use cases where outside interaction is required. | The canonical contract states popovers are non-modal (focus is not trapped, Tab leaves the popover freely) but does not model the AT-visibility of background content as a toggleable prop. React Aria distinguishes focus-trap modality (never applied to Popover) from AT-visibility modality (controlled by isNonModal). For most dialog popovers, isNonModal defaults to false (background hidden from AT) while focus is still allowed to leave — a nuanced split the canonical does not surface as an API dimension. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties | extended | + isKeyboardDismissDisabled (boolean, default false) — disables Escape key dismissal. When true, an alternative keyboard close path must be provided (e.g. a close Button wired to Dialog's close() render prop). | The canonical `dismissible` prop conflates Escape and outside-click dismiss; React Aria surfaces Escape suppression independently. This matters when a consumer needs outside-click dismiss to remain active (pointer users can dismiss) while preventing accidental Escape dismissal of a popover hosting in-flight form state. The canonical has no equivalent granularity. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties | extended | + arrowBoundaryOffset (number, default 0) — minimum distance in pixels between the OverlayArrow and the edge of the Popover container, preventing the arrow from overhanging the rounded corner. | Implementation detail of the arrow positioning system. The canonical arrow slot notes that arrow position auto-tracks the trigger but does not expose an edge-clearance numeric prop. React Aria surfaces this because rounded corners and small popovers require a minimum inset to keep the arrow visually on the container face. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
axes.properties | extended | + maxHeight (number, optional) — maximum block-size of the Popover in pixels. When content exceeds this height the overlay scrolls internally. | The canonical notes the body "scrolls internally only when content exceeds the popover's max-height" but does not expose a numeric prop to set this. React Aria provides maxHeight as a first-class prop passed through to the underlying overlay element. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
events[openChange] | reshaped | onOpenChange(isOpen: boolean) — same name and same boolean payload as canonical. However, React Aria exposes it both on <DialogTrigger> (for the controlled/uncontrolled wrapper pattern) and directly on <Popover> (for standalone triggerRef usage). The dismiss reason vocabulary ({ reason: 'escape' | 'outsideClick' | 'closeButton' | 'focusOutside' }) is not included in the payload. | The canonical openChange fires after the transition settles with a boolean. React Aria's onOpenChange fires on state transition intent (not after animation completes) and carries only the boolean. The canonical dismiss event (with reason discrimination) is absent; React Aria does not emit a unified dismiss reason event. The source event is available internally but not surfaced to the consumer via onOpenChange or a separate callback. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
events[dismiss] | omitted | — | React Aria Popover does not emit a unified dismiss event with reason discrimination ({ reason: 'escape' | 'outsideClick' | 'closeButton' | 'focusOutside' }). Consumers needing to distinguish dismiss paths must intercept lower-level events (onKeyDown for Escape, pointer handlers for outside-click) themselves. The canonical notes that React Aria's useOverlayTrigger exposes onClose(event) at the hook layer, but this is not surfaced on the react-aria-components Popover or DialogTrigger API. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
events[positionChange] | reshaped | data-placement attribute on the Popover div reflects the actual rendered placement after auto-flip. No discrete positionChange event or callback is emitted. Consumers can observe data-placement via a MutationObserver or read the CSS custom property if needed. | The canonical positionChange event carries { side, align } after flip. React Aria surfaces the actual placement as the data-placement CSS attribute only — no JavaScript event callback. This is sufficient for styling (flip animations, arrow rotation) but insufficient for consumers needing to re-anchor satellite UI via JavaScript when the placement changes. Source: https://react-aria.adobe.com/Popover (fetched 2026-05-31) |
Why this audit reads the way it does
React Aria Popover is a positioning and overlay-management primitive that owns the floating placement, portal mounting, and light-dismiss contract, while delegating dialog semantics, accessible naming, and all slot anatomy to consumer composition (Dialog, Heading, OverlayArrow, close Button). The five deepest structural divergences from the canon are: 1. No slot anatomy for header / title / body / close-button — React Aria Popover provides a blank container. All interior structure is composed by the consumer using Dialog + Heading + arbitrary children. The canonical header-bar sub-anatomy with title, close-button, and spacing tokens is entirely absent as a library API. 2. Variant axis collapsed to OverlayArrow presence — the canonical variant: standard | tip enum is expressed as the presence or absence of the <OverlayArrow> sub-component. The variant is a structural composition choice, not a prop, making it invisible to static API inspection. 3. side + align collapsed into placement compound string — the canonical separates side (top/right/bottom/left) from align (start/center/end) as orthogonal props. React Aria encodes both in a single placement string (e.g. 'bottom start') with 22 valid values, plus a numeric crossOffset for pixel-level adjustments. 4. dismissible split into two props — the canonical single `dismissible` boolean is split into isKeyboardDismissDisabled (Escape only) in React Aria, with outside-click dismissal always active. There is no single prop to disable all light-dismiss paths simultaneously, mirroring how the dismiss event is also absent. 5. Container role is conditional, not declarative — the Popover div auto-assigns role="dialog" only when no nested [role=dialog] child exists. The canonical container is always role="dialog". When consumers use the recommended Dialog-inside-Popover pattern, the role lives on the Dialog section, not the positioning container, creating a two-element ARIA boundary that the canonical models as one. The naming surface follows React Aria's 'should*' / 'is*' prefix convention (shouldFlip for flip, isKeyboardDismissDisabled for dismissible-Escape). The underlying ARIA output (aria-haspopup="dialog", aria-expanded, aria-controls, data-placement, role="dialog" on Dialog) is canonical-compliant at the AT-visible DOM level.
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
trigger | instance | Consumer's Button or other activator; in Figma the Popover frame is anchored to but does not contain the trigger |
container | frame | Floating frame with optional tip; positioned via Auto-Layout offset relative to the anchor |
arrow | rectangle | 8×8 triangle clipped from a rotated square; position bound to container edge |
header
from header-bar | frame | Auto-layout horizontal frame; padding from container token; spans full inline-size |
title
from header-bar | text | Heading text style; bound to a component property for content |
body | frame | Auto-layout vertical frame; intrinsic-size by default |
close-button
from close-button | instance | Icon button instance, "close" variant, inline-end position |
close-icon
from close-button | instance | × glyph or close-icon symbol, sized to the button's content area |
close-label
from close-button | text | Visually-hidden, localized "Close" (or context-specific equivalent) |
Token usage per slot
container- spacing
- padding
spacing.compact
- padding
- radius
- corner
radius.md
- corner
- color
- background
color.surface.raised - border
color.border.subtle
- background
- elevation
- shadow
elevation.lg
- shadow
arrow- color
- background
color.surface.raised - border
color.border.subtle
- background
header- spacing
- padding
spacing.tight - gap
spacing.tight
- padding
title- color
- foreground
color.text.primary
- foreground
- typography
- size
text.lg - weight
weight.semibold - lineHeight
leading.tight
- size
body- spacing
- padding
spacing.compact
- padding
- color
- foreground
color.text.primary
- foreground
- typography
- size
text.sm - lineHeight
leading.normal
- size
close-button- spacing
- padding
spacing.tight
- padding
- radius
- corner
radius.sm
- corner
- color
- foreground
color.text.muted - ring
color.border.focus
- foreground
Figma ↔ Code property map
| Figma | Kind | Code | Notes |
|---|---|---|---|
Variant | Enum | variant | Maps standard / tip. Tip variant always renders the arrow. |
Side | Enum | side | top / right / bottom / left. Authored placement preference; auto-flips when flip=true and viewport collides. |
Align | Enum | align | start / center / end. Aligns the popover along the perpendicular axis to side. |
Dismissible | Boolean | dismissible | Toggles light-dismiss handlers (outside-click + focus-outside). Escape always dismisses regardless of this property. |
Flip | Boolean | flip | Auto-flips to opposite side when authored placement collides with viewport. Generally true; disable for popovers whose visual context requires a fixed side. |
Has Arrow | Boolean | arrow | Auto-true when variant=tip. May be true on standard variant for design systems that ship arrow-on-everything; canonical default is false on standard. |
Has Header | Boolean | header | — |
Title | Text | title | — |
Body | Slot | body | Swap the body content slot (form, prose, list, custom layout). |
Has Close Button | Boolean | close | — |
Motion
| Transition | Duration token |
|---|---|
open | motion.duration.fast |
close | motion.duration.instant |
flip | motion.duration.fast |
Responsive behaviour
| Breakpoint | Change |
|---|---|
breakpoint.sm | At and below, popovers with rich body content (forms, multi-paragraph prose) may degrade to a bottom-anchored Drawer pattern — the `side` and `align` properties are ignored, the panel slides up from the bottom edge, and a backdrop appears. Lightweight tip-variant popovers continue to render anchored to their trigger. |
breakpoint.md | Above this width, all variants render as authored. The floating positioning honours `side` plus `align`; auto-flip and shift work as designed. |
Internationalisation
RTL · mirroring
The `side` property is direction-neutral (top / right / bottom / left are physical directions, not logical). The `align` property *is* logical: start aligns to the inline-start of the perpendicular axis (visual left in LTR, visual right in RTL). Tip-arrow position follows the align logical axis. Close-button position inside the header follows the existing logical pattern (close on inline-end). Auto-flip behaviour is symmetric — flipping from right to left in LTR mirrors flipping from left to right in RTL.
Text expansion
Popover panels size to their content by default; long titles and labels grow naturally within the panel's max-inline-size. For `variant: tip` with constrained width, German and Russian titles risk wrap — `variant: standard` with a larger inline-size budget is the canonical default in long-text locales. Body content scrolls within the popover's max-block-size; footer buttons may wrap to two rows under heavy expansion.
Variants, properties, states
Variants
Structurally different versions of the component.
standard tip Properties
The same component, parameterised.
| Property | Type |
|---|---|
side | top | right | bottom | left |
align | start | center | end |
dismissible | boolean |
flip | boolean |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | focus-visiblehover |
data | closedopeningopenclosing |
State transitions
| From | To | Trigger |
|---|---|---|
closed | opening | User activates the trigger that owns the popover (button click, keyboard activation). The popover is positioned relative to the trigger before paint; aria-expanded flips to true. |
opening | open | The enter animation completes (or, under prefers-reduced-motion reduce, immediately after closed-to-opening). Focus may move into the popover for form-style popovers; for menu-style or info-style popovers focus typically stays on the trigger. |
open | closing | User presses Escape, clicks outside the popover (when dismissible is true), tabs focus past the popover content and trigger together, or activates an action inside that programmatically closes. |
closing | closed | The exit animation completes (or immediately under reduced motion). aria-expanded flips to false; focus restores to the trigger if focus was inside the popover, otherwise stays where it was. |
Figma↔Code mismatches
- 01 Figma
A popover drawn as a static panel adjacent to its trigger
CodeA portal-mounted floating panel positioned by Floating UI / Popper / CSS anchor positioning, with light-dismiss handlers
ConsequenceThe Figma artifact captures position and visual treatment but encodes none of the floating positioning, the auto-flip behaviour, or the light-dismiss contract. Designers approximating the canonical popover may not realise the panel must escape its DOM container; developers shipping a DOM-nested div lose z-index control, get clipped by parent `overflow: hidden`, and fight stacking-context issues.
CorrectDocument the portal mount and floating-positioning contract in the canonical reference. The Figma file shows the visual panel anchored to its trigger; the canonical reference documents the DOM portal, the positioning library expectation, and the light-dismiss / focus contract.
- 02 Figma
Auto-flip drawn as a separate side variant for each viewport edge
CodeA single `flip: true` property that auto-positions when the authored side overflows
ConsequenceDesigners see four distinct popovers (top, right, bottom, left) in the design file; developers ship one with `flip: true`. The mock at viewport-bottom shows the bottom-anchored variant because the design file froze it; in production the popover auto-flips to top and the design no longer represents reality.
CorrectModel `flip` as a property the consumer toggles. The Figma component carries `side` as the authored preference; the canonical reference documents that production rendering flips when needed. Designers reviewing the rendered page should expect the popover to land on the authored side *unless* viewport collision forces a flip.
- 03 Figma
Tip arrow drawn as a separate decorative shape disconnected from the panel
CodeAn arrow rendered as a positioned pseudo-element or sub-component whose `inset-inline` updates with the trigger's bounding box
ConsequenceDesigners move the arrow manually when re-anchoring the popover in mocks; developers wire the arrow to the trigger programmatically. The mock and the production render disagree on arrow placement when the popover shifts due to flip or align changes.
CorrectDocument the arrow as a slot whose position derives from the trigger's bounding box. In Figma, parent the arrow inside the Popover frame so it moves with the panel; in code, the positioning library tracks both the panel and the arrow.
- 04 Figma
Modal popover variant drawn alongside the standard popover
CodeThere is no canonical "modal popover" — that pattern collapses to a Modal with edge-anchored positioning, which is just a Drawer
ConsequenceDesigners may invent a "popover with backdrop and focus trap" variant; developers either ship it (creating a modal panel that masquerades as a popover, confusing assistive tech) or reject it (forcing a redesign late). The vocabulary blurs between Popover, Drawer, and Modal.
CorrectTreat modal-vs-non-modal as a structural pattern boundary, not a popover variant. If the surface is non-modal, it's a Popover. If it's modal and edge-anchored, it's a Drawer with `variant: modal`. If it's modal and centred, it's a Modal. Document the three components with explicit `whenToUse.vsRelated` pointers.
Contracts
Non-negotiable contracts
Canon Popover is non-modal. Tab moves focus through the popover content and then out into the rest of the page. No focus trap. Light-dismiss (Escape + outside-click + focus-outside) handles closure.
Installing a focus trap turns Popover into a non-announcing Modal — Tab cycles inside, users cannot reach the rest of the page without dismissing first, and the surface lies about its modality. If genuine modality is needed, redesign as Modal or modal Drawer.
HTML specHTML Popover API + portal/Teleport for legacy The popover renders via a portal at the document root (or a dedicated overlay container) — not as a DOM child of its trigger. Native `<dialog popover>` and `popover="auto"` auto-portal in Baseline 2024+ browsers.
Rendering inline traps the popover under any ancestor's `overflow: hidden`; z-index cannot rescue it because the clipping is at paint time. A Card, a scrollable list, or an off-screen sidebar all silently clip popovers their children render.
APGAPG: Dialog (Modal) + Disclosure patterns — trigger semantics The trigger carries `aria-haspopup` (`"dialog"` for content popovers, `"menu"` for menu popovers), `aria-expanded` toggled with open state, and `aria-controls` referencing the popover container's id.
Without these attributes, SR users have no signal that the trigger reveals additional content, no signal that the surface is open or closed, and no programmatic path from trigger to popover. The relationship becomes sighted-pointer-only.
Vocabulary drift
- HTML
popover attribute / popover="auto"- Native HTML `popover` attribute (Baseline 2024) provides the portal-and-stacking primitive on top of which the canonical Popover layers its ARIA contract; bespoke implementations either build on it or polyfill via Floating UI / Popper.
- Radix
Popover- Radix exposes per-source dismiss callbacks (`onPointerDownOutside`, `onEscapeKeyDown`, `onFocusOutside`) rather than a unified `dismiss` event; consumers wrap to recover the canonical contract.
Common mistakes
#popover-no-aria-expanded
Trigger missing `aria-expanded` toggle
The trigger is a styled button with no `aria-expanded`. SR users have no signal whether the popover is currently open. `aria-haspopup` may also be missing.
Trigger always carries `aria-haspopup="dialog"` (or "menu" for menu-style popovers) and `aria-expanded` toggled in sync with the open/closed state. `aria-controls` references the popover container's id.
#popover-as-modal
Popover with focus trap installed
The popover is non-modal by canon, but the implementation installs a focus trap (often copy-pasted from Modal). Tab cycles within the popover; users cannot reach the rest of the page without dismissing first. Behaviour matches Modal while semantics claim Popover.
Remove the focus trap. Popover allows Tab to leave the panel; the user may tab through the popover content, then continue tabbing out into the page beneath. Light-dismiss (Escape + outside-click) handles closure. If the surface genuinely needs modality, redesign as a Modal or modal Drawer.
#popover-no-light-dismiss
Popover does not close on outside click or Escape
The popover only closes when the trigger is re-activated. Users learn to expect light-dismiss from the popover surface; a popover that traps interaction without modality feels broken. Escape unhandled also breaks the canonical dialog contract.
Implement light-dismiss when `dismissible: true` (the canonical default). Document-level Escape handler and outside-click handler both close. The `dismissible: false` variant is reserved for popovers hosting in-flight commits where accidental dismissal would lose data.
#popover-positioning-broken-on-scroll
Popover does not reposition when the page scrolls
The popover is positioned once on open with absolute coordinates. When the user scrolls, the popover stays anchored to its initial position while the trigger moves — visual disconnect.
Use a positioning library that tracks the trigger's bounding-box on scroll and resize (Floating UI's autoUpdate, Popper's eventListeners). For minimal cases, listen to scroll events on ancestors of the trigger and reposition; for production, the library is the right answer.
#popover-z-index-clipping
Popover clipped by parent `overflow: hidden`
The popover is rendered as a child of its trigger's DOM ancestor. A parent with `overflow: hidden` (a Card, a scrollable container) clips the popover when it extends beyond. Z-index does not help because the clipping is at paint time.
Render the popover via a portal at the document root (or a dedicated overlay container). Native `popover` attribute and `dialog` element with `popover="auto"` auto-portal in Baseline 2024+ browsers. For older support, use a `Portal` / `Teleport` primitive.
Accessibility hints
| Slot | Accessibility hint | |
|---|---|---|
trigger | Trigger carries `aria-haspopup="dialog"` (or "menu" for menu-style popovers), `aria-expanded` reflecting the popover state, and `aria-controls` referencing the container's id. Focus stays on the trigger after open by default; some patterns move focus into the popover content (e.g. when the popover hosts a form). The trigger is not part of the Popover component's own DOM — the consumer renders it. | |
container | Apply `role="dialog"` for popovers that contain interactive content. Label via `aria-labelledby` pointing at the title slot, or `aria-label` when no visible title exists. `aria-modal` is `false` (popovers are non-modal); focus is not trapped, the user may tab out of the popover into the rest of the page. | |
arrow | Decorative; do not put `role` on it. The relationship between trigger and popover is communicated by `aria-controls` and focus order, not by the arrow. | |
header | Header is a layout region (heading-region semantic), not a heading. Heading semantics live on the title element inside. Do not give the wrapper its own role attribute or heading level — APG dialog and tooltip patterns place the heading on the title element only. | |
title | Use a real heading element of an appropriate level (typically <h2> for top-level overlay surfaces). Reference it from the host container with aria-labelledby. Never rely on aria-label on the container if a visible title exists. Visually-hidden titles (sr-only) are valid when no visible title is shown but an accessible name is still required. | |
body | Body retains its native semantics. Interactive children receive focus via Tab when the popover opens (after the first focusable child if focus-on-open is configured). | |
close-button | Real <button type="button"> with an accessible name. Pick one of: aria-label="Close" OR a visually-hidden <span> child — declaring both is duplicative announcement. The Escape key must trigger the same action when the host surface is dismissible. | |
close-icon | aria-hidden="true" on the SVG. Never give the icon its own accessible name (title attribute, <title> child, or aria-label) — that double-announces alongside the host's name. | |
close-label | Visually-hidden <span> "Close" or "Close popover" inside the button, OR aria-label on the host. Use the disambiguated form when a dialog or another popover may be open concurrently. |