Dev 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.
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.
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
trigger | trigger | consumer-provided |
container | container | dialog-or-region |
arrow | arrow | presentational |
header | header | heading-region |
body | body | prose-or-form-or-list |
close-button | close | button |
Variants, properties, states
Variants
Structurally different versions of the component.
standardtip 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. |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-popover>` host plus the native HTML `popover` attribute on `<dialog popover>` for browsers that support it; named slots for `header` / `body` / `arrow` / `close`; trigger declared via `id` reference | attributes (`variant="tip"`, `side="bottom"`, `align="start"`, `dismissible`, `flip`); `data-state="open|closed|opening|closing"` for CSS |
| React | Radix `Popover.Root` / `Popover.Trigger` / `Popover.Portal` / `Popover.Content` compositing the slots; positioning via Floating UI; React Aria `usePopover` provides an alternative composition | props with class-variance-authority for variant; `side`, `align`, `sideOffset`, `alignOffset` props on `Popover.Content`; `data-state` attribute exposed for styling |
| Angular (signals) | Angular CDK Overlay with FlexibleConnectedPositionStrategy; `<cdk-overlay>` template plus content projection for header / body / arrow | input<'standard' | 'tip'>(); input<'top' | 'right' | 'bottom' | 'left'>(); `[align]` input maps to overlay's connected-position prefs |
| Vue | Headless UI `<Popover>` / `<PopoverButton>` / `<PopoverPanel>`; positioning via Floating UI's `useFloating` composable | defineProps with literal-union types; `:side`, `:align` props; `data-state` for CSS |
Events
openChangedismisspositionChange
Form integration
- name attribute
- Popover is a container, not a form control — it has no `name` attribute. Forms hosted inside the popover carry their own `name` attributes on their fields. Popover-hosted forms are common (filter pickers, inline edit forms); the form lifecycle is independent of the popover's open state.
- FormData serialization
- Forms inside the popover submit normally via their own `<form>` element. The popover itself contributes nothing to FormData. Submit-and-close patterns commonly call the popover close handler from the form's onSubmit; submit-without-close patterns keep the popover open after a successful submit.
- form.reset()
- Forms inside the popover respond to `form.reset()` independently of the popover's open state. Closing via Escape, outside-click, or focus-outside does not reset the form. Consumers wanting "discard on dismiss" call `form.reset()` from the popover's onClose handler.
- HTML5 validation
- Forms inside the popover use HTML5 validation as normal. Validation failures focus the first invalid field — the field is reachable because Popover does not trap focus, so focus lands naturally; the popover stays open until the user explicitly dismisses or successfully submits.
Performance thresholds
stackDepthopen-popover-count≥3popoversMultiple popovers may legitimately be open simultaneously on desktop (e.g. a primary popover plus a secondary popover anchored to a button inside it). The canonical practical maximum is 3; above this the visual hierarchy becomes confusing and focus management complexity exceeds the design benefit. Most real-world use stays at 1.
positionUpdateBudgetper-frame-cost≥4msThe positioning library updates the popover's transform on every scroll and resize frame. Position computation must stay under 4ms (one quarter of the 16ms 60fps budget) to leave headroom for the rest of the page. Floating UI hits this budget by default; bespoke implementations should profile against it on low-tier mobile.
Accessibility
| 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, not a heading. The actual heading semantics live on the title element inside. | |
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 | Provide an accessible name ("Close" or "Close popover"). Escape always dismisses regardless of close-button presence; click-outside dismisses when `dismissible` is true. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab (with focus on trigger, popover closed) | Focus moves to the next focusable in the document order. The trigger does not auto-open the popover on focus; Enter or Space activates. |
Enter or Space (with focus on trigger) | Activates the trigger; popover opens. Focus stays on the trigger by default for menu-style and info-style popovers; moves into the popover body for form-style popovers (consumer-configured). |
Tab (with popover open, focus on trigger) | Focus moves into the popover body, landing on the first focusable child. The popover does NOT trap focus — further Tab cycles eventually moves focus past the popover into the rest of the page, and the popover closes via the `focusOutside` dismiss path. |
Escape (with popover open) | Closes the popover regardless of where focus is (inside or on the trigger). Focus restores to the trigger. |
Shift+Tab (with focus inside popover, on the first focusable) | Focus returns to the trigger. Continued Shift+Tab moves to the previous document focusable; the popover closes via `focusOutside`. |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Popover opens | SR announces the popover content via the dialog labelling relationship (`aria-labelledby` if present, or `aria-label`). The trigger's `aria-expanded` flips to true, announced as state change. Focus may stay on the trigger (menu / info style) or move into the popover (form style); the announcement follows focus. |
| Popover closes | Trigger's `aria-expanded` flips to false; the trigger's accessible name is re-announced when focus restores there. No automatic close announcement is required for the popover itself. |
| Popover auto-flips on viewport collision | No SR announcement — the auto-flip is purely visual. Focus is unaffected; the popover content remains the same. |
axe-core rules to assert
aria-dialog-namearia-required-attraria-allowed-rolecolor-contrastfocus-order-semanticsaria-hidden-focus
Common mistakes
#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.
#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.
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.