Popover
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.
Preview
popover · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/popover
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Popover } from "@/components/ui/popover"<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
Use it when
- A short aside anchored to a control, such as a note explaining a label.
- A couple of options or a small form fragment, such as renaming a list.
- Anywhere dismissing without acting is a valid outcome, because the page behind stays live.
Do not use it when
Anatomy
PopoverBase UI's Popover.Root. Owns the open state, uncontrolled unless open is passed.Controlled byopenPopover.TriggerThe supplied button, with its target floor, focus ring and aria-expanded.Controlled bytriggerPopover.PopupThe floating panel, a role="dialog" through a portal, capped at a readable measure.Controlled bychildrenPopover.TitleThe heading, rendered when title is passed and wired to aria-labelledby.Controlled bytitlePopover.ArrowA card-coloured diamond pointing to the trigger, bordered on the outward edges.
- Popover 1renders no element
- Popover.Trigger 1data-slot="popover-trigger"
- Popover.Popup 1data-slot="popover-popup", role="dialog"
- Popover.Arrow 1data-slot="popover-arrow", aria-hidden
- Popover.Title 0..1data-slot="popover-title", optional
Examples
Anchored to a button
A short note anchored to a button, left uncontrolled. Escape or an outside press closes it and returns focus.
popover-anchored-to-a-button · base base · style base-lyraOpen under the product themeWith a form fragment
A labelled input and a save button. open and onOpenChange take the state over,
since a control inside closes it.
popover-with-a-form-fragment · base base · style base-lyraOpen under the product themeContent 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.
Do
"How readings are grouped", titled, short, easily dismissed.
Don’t
A blood pressure reading and its status, untitled: no name, no range, wrong surface.
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 byaria-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; underprefers-reduced-motiononly opacity crossfades. - No status colour, literal, ramp step or
pxsize: neutral chrome, theoverlayshadow token, and thebodyandheadlinesteps 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.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus to the trigger | One tab stop; the panel joins the tab order only when open. |
| Enter, Space | Opens the panel from the trigger | Focus moves onto its first focusable content. |
| Escape | Closes the panel | Focus returns to the trigger. |
| Tab, Shift+Tab | Moves through the focusable content in the panel | Focus is not trapped; past the last control it leaves the panel. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
| body text on the canvas material | light | 104.7 | 17.96:1 | Pass |
| body text on the canvas material | dark | -100.5 | 18.00:1 | Pass |
| body text on the inset material | light | 98.1 | 16.32:1 | Pass |
| body text on the inset material | dark | -100.1 | 17.18:1 | Pass |
| body text on the card material | light | 104.7 | 17.96:1 | Pass |
| body text on the card material | dark | -99.6 | 16.32:1 | Pass |
| body text on the raised material | light | 104.7 | 17.96:1 | Pass |
| body text on the raised material | dark | -97.5 | 13.77:1 | Pass |
| body text on the sheet material over the darkest backdrop | light | 99.9 | 16.76:1 | Pass |
| body text on the sheet material over the darkest backdrop | dark | -99.7 | 16.47:1 | Pass |
| body text on the sheet material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the sheet material over the lightest backdrop | dark | -80.8 | 6.07:1 | Pass |
| body text on the overlay material over the darkest backdrop | light | 85.9 | 13.48:1 | Pass |
| body text on the overlay material over the darkest backdrop | dark | -99.9 | 16.77:1 | Pass |
| body text on the overlay material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the overlay material over the lightest backdrop | dark | -54.2 | 2.69:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from PopoverProps in registry/bases/base/popover.tsx.
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
Tooltip
A short supplementary label that appears when a pointer rests on a control or a keyboard moves focus to it, for information a reader can do without.
Menu
A list of actions opened from a button, built on Base UI Menu, that draws neutral chrome and keeps every safety-relevant action out on the screen.