---
title: "Popover"
description: "A small panel anchored to the control that opened it, floated in a portal and dismissed by leaving it, built as a thin themed wrapper over Base UI Popover."
url: "https://opsinjs.pensievelabs.org/components/popover"
source: "https://opsinjs.pensievelabs.org/components/popover.md"
section: "Components"
status: "shipped"
kind: "component"
category: "overlay"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["anchored panel", "flyout"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="popover"
  status="shipped"
  questions="[
  &#x22;Nobody has heard a popover in a screen reader, so whether a non-modal dialog over a live page reads usefully is open.&#x22;,
  &#x22;No contrast pair has been measured: the card panel, its hairline, the arrow and the trigger are argued from the roles rather than checked.&#x22;,
  &#x22;Under forced-colors: active the shadow drops and the arrow takes the platform's own pair, and no high-contrast reader has seen it.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="popover" />

## Installation [#installation]

<ComponentInstall name="popover" unbuilt="false" importPath="@/components/ui/popover" dependencies="[&#x22;@base-ui/react&#x22;]" />

## Usage [#usage]

```tsx
import { Popover } from "@/components/ui/popover"
```

```tsx
<Popover title="How readings are grouped" trigger="Why two lists?">
  <p>
    Entries you add yourself sit in one list, and entries from a connected
    device sit in another, so you can always tell which is which.
  </p>
</Popover>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A short aside anchored to a control, such as a note explaining a label.&#x22;,
  &#x22;A couple of options or a small form fragment, such as renaming a list.&#x22;,
  &#x22;Anywhere dismissing without acting is a valid outcome, because the page behind stays live.&#x22;,
]"
  avoid="[
  { case: &#x22;The reader must answer before they carry on, so dismissing would leave the product guessing.&#x22;, instead: &#x22;dialog&#x22; },
  { case: &#x22;The content is a large form, or needs its own scroll and a docked layout.&#x22;, instead: &#x22;sheet&#x22; },
  { case: &#x22;You only want a short passive label, nothing to open or dismiss.&#x22;, instead: &#x22;term&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="popover"
  parts="[
  {
    name: &#x22;Popover&#x22;,
    describes: &#x22;Base UI's Popover.Root. Owns the open state, uncontrolled unless open is passed.&#x22;,
    prop: &#x22;open&#x22;,
  },
  {
    name: &#x22;Popover.Trigger&#x22;,
    describes: &#x22;The supplied button, with its target floor, focus ring and aria-expanded.&#x22;,
    prop: &#x22;trigger&#x22;,
  },
  {
    name: &#x22;Popover.Popup&#x22;,
    describes: &#x22;The floating panel, a role=\&#x22;dialog\&#x22; through a portal, capped at a readable measure.&#x22;,
    prop: &#x22;children&#x22;,
  },
  {
    name: &#x22;Popover.Title&#x22;,
    describes: &#x22;The heading, rendered when title is passed and wired to aria-labelledby.&#x22;,
    prop: &#x22;title&#x22;,
  },
  {
    name: &#x22;Popover.Arrow&#x22;,
    describes: &#x22;A card-coloured diamond pointing to the trigger, bordered on the outward edges.&#x22;,
  },
]"
/>

<CompositionTree
  name="popover"
  tree="[
  {
    part: &#x22;Popover&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;renders no element&#x22;,
    children: [
      {
        part: &#x22;Popover.Trigger&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;data-slot=\&#x22;popover-trigger\&#x22;&#x22;,
      },
      {
        part: &#x22;Popover.Popup&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;data-slot=\&#x22;popover-popup\&#x22;, role=\&#x22;dialog\&#x22;&#x22;,
        children: [
          {
            part: &#x22;Popover.Arrow&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;data-slot=\&#x22;popover-arrow\&#x22;, aria-hidden&#x22;,
          },
          {
            part: &#x22;Popover.Title&#x22;,
            cardinality: &#x22;0..1&#x22;,
            note: &#x22;data-slot=\&#x22;popover-title\&#x22;, optional&#x22;,
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### Anchored to a button [#anchored-to-a-button]

A short note anchored to a button, left uncontrolled. Escape or an outside press
closes it and returns focus.

<ComponentPreview name="popover-anchored-to-a-button" kind="example" align="start" />

### With a form fragment [#with-a-form-fragment]

A labelled input and a save button. `open` and `onOpenChange` take the state over,
since a control inside closes it.

<ComponentPreview name="popover-with-a-form-fragment" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Give every popover a `title`: the panel is a `role="dialog"`, and the title is the
name a screen reader announces. Keep the body to a sentence or two, and never put a
clinical reading or verdict loose inside one.

<DoDont>
  <DoDont.Do>
    **"How readings are grouped"**, titled, short, easily dismissed.
  </DoDont.Do>

  <DoDont.Dont>
    **A blood pressure reading and its status**, untitled: no name, no range, wrong surface.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

Audited against WCAG 2.2 AA on `/view/base/base-lyra/component/popover`, in a source
pass and a rendered pass. Author-run, not independent; clinical review is pending.

**Fixed.** The trigger's hit area was floored on height alone, so an icon-only
trigger could clear the AA 24px minimum while still missing the house width floor.
A matching `min-w` at `--opsin-target-minimum` now floors it too, following the
`icon-button` pattern.

**Confirmed.**

* The panel is `role="dialog"`, named by `aria-labelledby`; a missing title warns.
* Focus moves into the panel on open and back to the trigger on close, by Base UI.
* The arrow is `aria-hidden`; under `prefers-reduced-motion` only opacity crossfades.
* No status colour, literal, ramp step or `px` size: neutral chrome, the `overlay`
  shadow token, and the `body` and `headline` steps only.

**Residual.** The wrapper takes no `aria-label` prop, so an icon-only trigger needs
a name from the caller's trigger node; every shipped trigger already uses named text.
Contrast pairs remain unmeasured, below.

<KeyboardTable
  name="popover"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus to the trigger&#x22;,
    notes: &#x22;One tab stop; the panel joins the tab order only when open.&#x22;,
  },
  {
    keys: &#x22;Enter, Space&#x22;,
    action: &#x22;Opens the panel from the trigger&#x22;,
    notes: &#x22;Focus moves onto its first focusable content.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Closes the panel&#x22;,
    notes: &#x22;Focus returns to the trigger.&#x22;,
  },
  {
    keys: &#x22;Tab, Shift + Tab&#x22;,
    action: &#x22;Moves through the focusable content in the panel&#x22;,
    notes: &#x22;Focus is not trapped; past the last control it leaves the panel.&#x22;,
  },
]"
/>

<ContrastReport component="popover" />

## API reference [#api-reference]

<PropsTable name="PopoverProps" />

`trigger` is the button's visible content; a button here would nest inside another.
`side` defaults to `bottom`, a preference the primitive overrides on collision.
`className` merges onto the panel and must resolve neither colour axis.

## Related [#related]

* [Dialog](./dialog.mdx) blocks the page until the reader answers.
* [Sheet](./sheet.mdx) docks to an edge, built for touch and larger forms.
* [Tooltip](./tooltip.mdx) is a passive hover label; a phone has no hover.
* [Term](./term.mdx) shows a word's everyday meaning inline, nothing to open.
