Popover
When to use
Section titled “When to use”- 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:
| Component | Use it for | For example |
|---|---|---|
| Dialog | Something that needs a decision or input before the user carries on; the page is blocked meanwhile | Confirm a delete, edit a record in a form |
| Drawer | A panel that slides in from an edge and blocks the page; the first choice on a phone | Share options, a mobile nav, a filter panel |
| Popover | Extra content or controls next to a button, while the rest of the page stays usable | Filter panel, share options, details card |
| Menu | A list of actions or settings opened from a button | “More actions ⋯”, a View menu |
| Tooltip | A short text label on hover or focus; nothing in it can be clicked | Name of an icon button, a keyboard shortcut |
| HoverCard | A preview of where a link goes, on hover or focus; nothing essential in it | A profile behind an @mention, a repository’s summary |
| Toast | A brief confirmation after an action, which goes away by itself | “Saved”, “Archived — Undo” |
| Alert | A 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> );}Anatomy
Section titled “Anatomy”import { Popover } from '@arun-dev/ui';
<Popover.Root> <Popover.Trigger /> <Popover.Popup> <Popover.Close /> </Popover.Popup></Popover.Root>;| Part | Renders | Does |
|---|---|---|
Root | nothing | holds 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.
Placement
Section titled “Placement”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.
Closing
Section titled “Closing”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.
Events
Section titled “Events”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.
| Part | Renders | Its 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:
| Prop | Fires |
|---|---|
onBeforeToggle | before each open and close. event.preventDefault() can stop an open only |
onToggle | after 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.
Press Esc, click outside, or use the button.
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.
Popover or Dialog?
Section titled “Popover or Dialog?”| Popover | Dialog | |
|---|---|---|
| page behind it | stays usable | inert |
| position | next to its trigger | centred |
| a click outside | closes it | closes it, unless turned off |
| for | filters, pickers, small forms | decisions, larger forms |
Popover.Root
Section titled “Popover.Root”| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | Controlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount. | |
defaultOpen | boolean | false | Initial state when uncontrolled. Read once, at mount. |
onOpenChange | (open: boolean) => void | Called on every request to open or close: Trigger, Close, Esc or a click outside. A controlled popover moves only if you accept it. | |
children | ReactNode | The other parts. Root renders no element of its own. |
Popover.Popup
Section titled “Popover.Popup”| Prop | Type | Default | Description |
|---|---|---|---|
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. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything 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-sideanddata-alignrepeat 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', orComponentProps<typeof Popover.Popup>.