Skip to content

Popover

  • A filter panel or settings next to the button that opens them
  • Details about an item, with links or buttons inside
  • A small form that should not block the page

Not for a plain text label — use a Tooltip — or a task that must be finished first — use a Dialog.

Choosing between messages and popups:

ComponentUse it forFor example
DialogSomething that needs a decision or input before the user carries on; the page is blocked meanwhileConfirm a delete, edit a record in a form
DrawerA panel that slides in from an edge and blocks the page; the first choice on a phoneShare options, a mobile nav, a filter panel
PopoverExtra content or controls next to a button, while the rest of the page stays usableFilter panel, share options, details card
MenuA list of actions or settings opened from a button“More actions ⋯”, a View menu
TooltipA short text label on hover or focus; nothing in it can be clickedName of an icon button, a keyboard shortcut
HoverCardA preview of where a link goes, on hover or focus; nothing essential in itA profile behind an @mention, a repository’s summary
ToastA brief confirmation after an action, which goes away by itself“Saved”, “Archived — Undo”
AlertA message that stays in the page until the situation changes“Your trial ends in 3 days”, a form error summary

Popover opens a panel next to the button that opened it. It is a native popover, so the platform supplies the top layer, closing on Esc or a click outside, the Tab order from the trigger into the panel, and focus returning to the trigger. CSS anchor positioning keeps it attached through scrolling and resizing — there is no positioning script.

import { useState } from 'react';
import { Button, Checkbox, Popover } from '@arun-dev/ui';
const list = { display: 'grid', gap: 'var(--space-2xs)', margin: 0, padding: 0 };
const footer = { display: 'flex', justifyContent: 'flex-end', marginBlockStart: 'var(--space-sm)' };
export default function PopoverBasics() {
const [archived, setArchived] = useState(false);
return (
<Popover.Root>
{/* Trigger and Close are plain buttons: render a Button to style them. */}
<Popover.Trigger render={<Button />}>Filters</Popover.Trigger>
<Popover.Popup aria-label="Filters" align="start">
<div style={list}>
<div style={{ display: 'flex', gap: 'var(--space-2xs)', alignItems: 'center' }}>
<Checkbox.Root
id="popover-archived"
checked={archived}
onCheckedChange={(c) => setArchived(c === true)}
>
<Checkbox.Indicator />
</Checkbox.Root>
<label htmlFor="popover-archived">Show archived</label>
</div>
</div>
<div style={footer}>
<Popover.Close render={<Button variant="primary" />}>Done</Popover.Close>
</div>
</Popover.Popup>
</Popover.Root>
);
}
import { Popover } from '@arun-dev/ui';
<Popover.Root>
<Popover.Trigger />
<Popover.Popup>
<Popover.Close />
</Popover.Popup>
</Popover.Root>;
PartRendersDoes
Rootnothingholds open, and the setter every other part goes through
Trigger<button>toggles it; the element it is anchored to
Popup<div>the panel: popover="auto", role="dialog", placed by side/align
Close<button>closes

Trigger and Close are unstyled buttons, so render a Button to style them. Name the popup with aria-label, or aria-labelledby pointing at a heading inside it.

side picks the side of the trigger, and align where along it: flush with the trigger’s start or end edge, or centred. When there isn’t room, it flips to the opposite side. Try the edges of the window:

import { Button, Popover } from '@arun-dev/ui';
const SIDES = ['top', 'right', 'bottom', 'left'] as const;
export default function PopoverSides() {
return (
<>
{SIDES.map((side) => (
<Popover.Root key={side}>
<Popover.Trigger render={<Button />}>{side}</Popover.Trigger>
<Popover.Popup side={side} aria-label={`Opens on the ${side}`}>
Opens on the {side}, and flips if there is no room.
</Popover.Popup>
</Popover.Root>
))}
</>
);
}

The gap between trigger and panel is --popover-offset.

In an older browser

Placement needs CSS anchor positioning: Chrome 125, Firefox 147 or Safari 26. An older browser still opens the popover and closes it on Esc or a click outside, but shows it centred in the window rather than beside the trigger.

Every close is a request through onOpenChange(false): Popover.Close, Esc, a click outside, clicking the Trigger again, or opening another popover — only one is open at a time, unless one is inside the other. Esc and a click outside close the element before anyone is asked, so a controlled parent that refuses has it reopened.

Where to attach them. Each part renders a real element and passes your props on to it, so any event that element fires — not only those listed here — is a prop on that part. Popover.Root renders nothing, so it has only onOpenChange.

PartRendersIts events, for example
Popover.Popup<div popover>onBeforeToggle, onToggle, onKeyDown, onTransitionEnd
Popover.Trigger, Popover.Close<button>onClick, onFocus, onKeyDown

onOpenChange on the Root reports every open and close, but not what caused it. The popover’s own events on Popover.Popup fire on every one too. Yours run before the component’s:

PropFires
onBeforeTogglebefore each open and close. event.preventDefault() can stop an open only
onToggleafter each open and close; event.newState is 'open' or 'closed'

Stopping an open in onBeforeToggle keeps the popover hidden, and onOpenChange(false) reports it, so the state and the Trigger follow. When opening, event.nativeEvent.source is the Trigger that opened it (Chrome 140, Firefox 145, Safari 26.5) — on the native event only; React’s event leaves it out.

Events appear here.
import { useState } from 'react';
import { Button, Popover } from '@arun-dev/ui';
const logStyle = {
margin: 0,
minBlockSize: '7.5rem',
fontFamily: 'var(--font-mono, monospace)',
fontSize: 'var(--text-xs)',
color: 'var(--color-text-secondary)',
};
export default function PopoverEvents() {
const [log, setLog] = useState<string[]>([]);
const add = (line: string) => setLog((lines) => [...lines.slice(-5), line]);
return (
<div style={{ display: 'grid', gap: 'var(--space-xs)', justifyItems: 'start' }}>
<Popover.Root onOpenChange={(open) => add(`onOpenChange(${open})`)}>
<Popover.Trigger render={<Button />}>Open, then close it any way</Popover.Trigger>
{/* Native events on the Popup: yours run first, on every open and close. */}
<Popover.Popup
aria-label="Events"
onBeforeToggle={(event) => add(`onBeforeToggle → ${event.newState}`)}
onToggle={(event) => add(`onToggle → ${event.newState}`)}
>
<p style={{ margin: 0 }}>Press Esc, click outside, or use the button.</p>
<Popover.Close render={<Button />}>Close</Popover.Close>
</Popover.Popup>
</Popover.Root>
<pre style={logStyle} aria-live="polite">
{log.length ? log.join('\n') : 'Events appear here.'}
</pre>
<Button onClick={() => setLog([])}>Clear log</Button>
</div>
);
}

They don’t tell Esc from a click outside — both look the same above. If that matters, listen for the Esc key yourself; a Close button’s own onClick identifies it.

onOpenChange(false) is a request, so it can arrive before the popover is hidden: a Close button or the Trigger report first, then it hides; Esc and a click outside hide it first. For work that needs it actually hidden, use onToggle with event.newState === 'closed', which always comes after.

Not reported: opening it by calling showPopover() yourself — use open and onOpenChange instead — or unmounting it while open.

PopoverDialog
page behind itstays usableinert
positionnext to its triggercentred
a click outsidecloses itcloses it, unless turned off
forfilters, pickers, small formsdecisions, larger forms
Popover.Root props
PropTypeDefaultDescription
openbooleanControlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultOpenbooleanfalseInitial state when uncontrolled. Read once, at mount.
onOpenChange(open: boolean) => voidCalled on every request to open or close: Trigger, Close, Esc or a click outside. A controlled popover moves only if you accept it.
childrenReactNodeThe other parts. Root renders no element of its own.
Popover.Popup props
PropTypeDefaultDescription
side'top' | 'bottom' | 'left' | 'right''bottom'Which side of the Trigger to open on. Flips to the opposite side when there is no room.
align'start' | 'center' | 'end''center'Flush with the Trigger's start or end edge, or centred on it.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.

Popover.Trigger and Popover.Close take the props of the <button> they render, plus render and ref.

  • The content stays mounted while closed, so it keeps its state between openings.
  • data-side and data-align repeat what you asked for, not where a flip moved it — which side it flipped to is not visible to script, so style both sides alike.
  • Restyle every popover with the --popover-* tokens, e.g. --popover-max-width.
  • Types come from the package that owns them: import type { PopoverPopupProps } from '@arun-dev/headless/popover', or ComponentProps<typeof Popover.Popup>.