Bridge 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.

Highlight
Fig 1.1 · Popover · Bridge view
Both

Figma↔Code mismatches

Where designer and developer worlds typically misalign on this component.

  1. 01
    Figma

    A popover drawn as a static panel adjacent to its trigger

    Code

    A portal-mounted floating panel positioned by Floating UI / Popper / CSS anchor positioning, with light-dismiss handlers

    Consequence

    The 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.

    Correct

    Document 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.

  2. 02
    Figma

    Auto-flip drawn as a separate side variant for each viewport edge

    Code

    A single `flip: true` property that auto-positions when the authored side overflows

    Consequence

    Designers 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.

    Correct

    Model `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.

  3. 03
    Figma

    Tip arrow drawn as a separate decorative shape disconnected from the panel

    Code

    An arrow rendered as a positioned pseudo-element or sub-component whose `inset-inline` updates with the trigger's bounding box

    Consequence

    Designers 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.

    Correct

    Document 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.

  4. 04
    Figma

    Modal popover variant drawn alongside the standard popover

    Code

    There is no canonical "modal popover" — that pattern collapses to a Modal with edge-anchored positioning, which is just a Drawer

    Consequence

    Designers 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.

    Correct

    Treat 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.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

standardtip

Properties

The same component, parameterised.

PropertyType
sidetop | right | bottom | left
alignstart | center | end
dismissibleboolean
flipboolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
focus-visiblehover
data
closedopeningopenclosing
Both

Figma ↔ Code property map

FigmaTypeCodeNotes
VariantVariantvariantMaps standard / tip. Tip variant always renders the arrow.
SideVariantsidetop / right / bottom / left. Authored placement preference; auto-flips when flip=true and viewport collides.
AlignVariantalignstart / center / end. Aligns the popover along the perpendicular axis to side.
DismissibleBooleandismissibleToggles light-dismiss handlers (outside-click + focus-outside). Escape always dismisses regardless of this property.
FlipBooleanflipAuto-flips to opposite side when authored placement collides with viewport. Generally true; disable for popovers whose visual context requires a fixed side.
Has ArrowBooleanarrowAuto-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 HeaderBooleanheader
TitleTexttitle
BodyInstance SwapbodySwap the body content slot (form, prose, list, custom layout).
Has Close ButtonBooleanclose
Both

State transitions

FromToTrigger
closedopeningUser 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.
openingopenThe 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.
openclosingUser 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.
closingclosedThe 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.
Designer

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 frame Auto-layout horizontal frame; padding from container token
body frame Auto-layout vertical frame; intrinsic-size by default
close-button instance Icon button instance; visibility bound to dismissible vs explicit-close pattern
Dev

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
Dev

Cross-framework expression

FrameworkStructure mechanismVariant 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
Both

Events

  1. openChange
    Payload
    Boolean. `true` after the popover has finished entering, `false` after it has finished exiting. Fires after the transition settles, not at the start.
    Web Components
    `openChange` CustomEvent on the host with `event.detail = { open: boolean }`; native `<dialog popover>` emits `toggle` event with old/new states.
    React
    `onOpenChange(open: boolean)` controlled-pattern callback (Radix Popover, Headless UI Popover).
    Angular Signals
    `output<boolean>('openChange')`; pair with `[(open)]` two-way binding.
    Vue
    `@update:open` for `v-model:open`. Headless UI also exposes a `<PopoverGroup>` parent to coordinate sibling popovers.
  2. dismiss
    Payload
    `{ reason: 'escape' | 'outsideClick' | 'closeButton' | 'focusOutside' }`. Distinguishes user-initiated dismissal paths. The `focusOutside` reason fires when Tab moves focus past the popover and trigger, completing the canonical "tab past to dismiss" pattern.
    Web Components
    Native `<dialog popover>` fires a single `toggle` event; bespoke wrappers re-emit a `dismiss` CustomEvent with reason-discrimination from the source event.
    React
    Radix Popover does not expose a unified dismiss event; consumers wire `onPointerDownOutside`, `onEscapeKeyDown`, and `onFocusOutside` separately and synthesise the union. React Aria's `useOverlayTrigger` exposes `onClose(event)` with the source event.
    Angular Signals
    `output<DismissReason>('dismiss')` paralleling `openChange`.
    Vue
    `@dismiss` event with payload `{ reason }`; consumers needing the reason wrap `@close` from the underlying library.
  3. positionChange
    Payload
    `{ side, align }` with the actual rendered placement after auto-flip and shift. Differs from the authored `side` and `align` props when viewport collision triggers a flip. Lets consumers re-anchor satellite UI (toasts, secondary popovers) to the actual rendered position.
    Web Components
    `positionChange` CustomEvent with `event.detail = { side, align }`. Optional event — many hosts do not surface the actual rendered placement.
    React
    Floating UI's `useFloating` returns the `placement` value; Radix Popover does not emit a discrete event but the `data-side` / `data-align` attributes on `Popover.Content` reflect the actual rendered placement.
    Angular Signals
    `output<{ side: PopoverSide, align: PopoverAlign }>('positionChange')`.
    Vue
    `@position-change` event; alternative is to bind `:side` and `:align` and read them in the parent.
Both

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.
Both

Performance thresholds

  • stackDepthopen-popover-count3popovers

    Multiple 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-cost4ms

    The 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.

Both

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.

Both

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.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
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

TriggerExpected
Popover opensSR 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 closesTrigger'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 collisionNo SR announcement — the auto-flip is purely visual. Focus is unaffected; the popover content remains the same.

axe-core rules to assert

  • aria-dialog-name
  • aria-required-attr
  • aria-allowed-role
  • color-contrast
  • focus-order-semantics
  • aria-hidden-focus
Both

Common mistakes

#popover-as-modal

Popover with focus trap installed

Problem

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.

Fix

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

Problem

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.

Fix

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

Problem

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.

Fix

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`

Problem

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.

Fix

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

Problem

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.

Fix

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.