Popover
StableA panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
usePopover holds the open state and the trigger’s ref; spread its props onto <Popover>. The panel sits in the browser’s top layer, so no scrolling parent or z-index can cut it off, yet it still inherits the theme and fonts from where it is written. Script only places it: computePosition puts it beside the trigger, flips it when there is no room and keeps it 8 pixels inside the window.
A small form
import { useRef, useState, type FormEvent } from 'react'import { Button, Field, Popover, Switch, TextInput, usePopover } from '@rchursin/product/ui'
type Limit = { dollars: number; pause: boolean }
/** * usePopover holds the open state and the anchor; spread pop.props onto <Popover>. Focus moves to * the first field, Enter saves, Esc or a click outside closes, and focus returns to the trigger. * The form is keyed by each opening, so it always starts from the saved values. */export default function PopoverForm() { const pop = usePopover() const [limit, setLimit] = useState<Limit>({ dollars: 25, pause: true }) const [round, setRound] = useState(0)
return ( <div className="flex flex-col items-center gap-2"> <Button ref={pop.anchorRef} variant="secondary"Usage
import { Popover, usePopover } from '@rchursin/product/ui'const pop = usePopover()
<Button ref={pop.anchorRef} onClick={pop.toggle} aria-haspopup="dialog" aria-expanded={pop.open}> Daily limit</Button><Popover {...pop.props} label="Daily spend limit">…</Popover>Examples
Placement
bottom-start, bottom and bottom-end under a table header. A popover that holds only text focuses that text (initialFocus), so it is read at once.
import { useRef, type ReactNode } from 'react'import { cx } from '@rchursin/components/cx'import { IconButton, Popover, usePopover, type Placement } from '@rchursin/product/ui'
/** * Three placements under a table header. A popover with only text moves focus to that text * (initialFocus), so a screen reader reads it at once; Esc returns focus to the button. * Near an edge it shifts to stay on screen, and flips above when there is no room below. */export default function PopoverPlacement() { return ( <div className="grid w-full max-w-md grid-cols-3 gap-2 border-b border-line pb-2 font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase"> <Definition term="Tokens" placement="bottom-start"> Input plus output tokens for the whole run, across every model call. </Definition> <Definition term="Cost" placement="bottom" className="justify-center"> What the run cost at the model’s list price on the day it ran. </Definition> <Definition term="Latency" placement="bottom-end" className="justify-end"> The p95: 95 of every 100 model calls in the run answered faster than this.API
Popover props
| Prop | Type | Default | Description |
|---|---|---|---|
openrequired | boolean | – | Shown when true. Usually from pop.props. |
onOpenChangerequired | (open: boolean) => void | – | Called with false when the browser closes it (Esc or a click outside). Usually from pop.props. |
anchorRefrequired | RefObject<HTMLElement | null> | – | The trigger. The panel is placed against it and focus returns to it. |
placement | Placement | 'bottom-start' | 'bottom-start' | 'bottom-end' | 'bottom' | 'top-start' | 'top-end' | 'top' | 'right-start' | 'left-start'. Below flips above (and above flips below) when there is no room; right flips left. |
offset | number | 6 | Gap between the trigger and the panel, in pixels. |
matchWidth | boolean | false | Makes the panel at least as wide as the trigger, for select-like pickers. |
label | string | – | The panel’s accessible name (aria-label). Give one whenever the role is dialog. |
role | 'dialog' | 'menu' | 'listbox' | 'none' | 'dialog' | The panel’s role. Use none when the content brings its own: Menu puts role="menu" on its list. |
initialFocus | RefObject<HTMLElement | null> | – | What to focus on open. Default: the first focusable element inside; if there is none, focus stays where it was. |
returnFocus | boolean | true | When it closes (Esc, a click outside, or your code), put focus back on the trigger, but only if focus was inside the panel. |
trapFocus | boolean | false | Makes it modal for the keyboard, as the APG date picker dialog: Tab and Shift+Tab wrap between the first and last controls inside, and a dialog gets aria-modal. Esc and a click outside still close it. The date pickers turn it on. |
surface | 'panel' | 'none' | 'panel' | panel wraps the content in the raised card (rc-pop-panel); none leaves the styling to you. |
className | string | – | Classes on the card. Ignored with surface none. |
childrenrequired | ReactNode | – | The content. |
id | string | – | id on the popover element, for aria-controls on the trigger. |
usePopover(initial?)
| Prop | Type | Default | Description |
|---|---|---|---|
initial | boolean | false | Argument: start open. |
open | boolean | – | Returned: whether it is open. Put it on the trigger as aria-expanded. |
setOpen | (open: boolean) => void | – | Returned: open or close it from code (Save, Cancel). |
toggle | () => void | – | Returned: for the trigger’s onClick (pass the event). A pointer click within 250 ms of the popover closing is ignored, so clicking the trigger of an open popover (which light-dismisses it first) closes it instead of reopening it. Enter and Space always toggle, so Esc then a quick Enter reopens it. |
anchorRef | RefObject<HTMLButtonElement | null> | – | Returned: put it on the trigger. |
props | { open, onOpenChange, anchorRef } | – | Returned: spread onto <Popover>. |
computePosition(anchor, panel, placement, offset?)
| Prop | Type | Default | Description |
|---|---|---|---|
anchorrequired | DOMRect | – | The trigger’s getBoundingClientRect(). |
panelrequired | { width: number; height: number } | – | The panel’s size. |
placementrequired | Placement | – | Where you would like it. |
offset | number | 6 | Gap in pixels. |
returns | { top: number; left: number; side: 'top' | 'bottom' | 'left' | 'right' } | – | Viewport pixels for a position: fixed element, and the side it ended up on. Reads the window size, so call it in the browser. Tooltip and Combobox use it too. |
CSS
| Class or variable | What it does |
|---|---|
rc-pop | The popover element: fixed position, no browser border or padding, inherited colour. With motion allowed it fades in while settling 4 pixels down, and fades out moving 4 pixels back toward its trigger. |
rc-pop-panel | The raised card: 12 pixel corners, the raised surface and the deepest elevation shadow (with a faint gradient in dark mode). |
data-side | Set on .rc-pop once placed: top, bottom, left or right. A panel above its trigger (top) leaves downward, toward it. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| EnterSpace | On the trigger: opens it and moves focus to the first control inside, or to initialFocus. |
| TabShiftTab | Moves through the panel. By default it is non-modal: Tab past the last control carries on to the page and closes the panel, so it never floats over the page behind you; Shift+Tab from the first goes back to the trigger and leaves it open. With trapFocus, Tab wraps around inside instead. |
| Esc | Closes it. If focus was inside the panel, it goes back to the trigger. |
- The panel is role="dialog" by default and named with label. It is non-modal: the rest of the page still works while it is open, and it closes when focus moves on to something outside it and its trigger. With trapFocus it keeps keyboard focus inside and says so with aria-modal="true".
- The trigger is yours to mark up: add aria-haspopup="dialog" and aria-expanded={pop.open}, as the examples do.
- It shows in the top layer but stays in the DOM where you write it. Write it right after its trigger, as the examples do, so reading order and Tab order follow the trigger.
- Focus moves in on open. When Esc or a click outside closes it, focus returns to the trigger, unless you clicked another control, which keeps it.
- Opening, closing, Esc and outside clicks are the browser’s own popover behaviour; script only does the placement.
Guidelines
Do
- Use it for a small task tied to one control: a limit, a date, a filter.
- Label the panel, and mark the trigger with aria-haspopup and aria-expanded.
- Key a draft by each opening, so closing without saving really cancels.
Don’t
- Don’t put a long form in a popover; use a dialog.
- Don’t open it on hover; hover hints are tooltips.
- Don’t count on it to keep focus inside unless you pass trapFocus; by default it is not modal.
In use
Related
- MenuA menu button with actions, checkbox and radio items, group labels, shortcut hints and type-ahead, in a popover beside its trigger.
- DialogA modal on the native <dialog> element: the page behind goes inert, Esc closes it, and focus goes back to what opened it.
- TooltipA few words on hover (after a short wait) or keyboard focus, in the top layer. Esc hides it. Best for naming icon buttons.
- PickerA filterable list of options, on its own or in a popover: type to filter, arrows and Enter to choose, or 1–9 to pick at once.
- Date pickerA field that shows the chosen date and opens a month grid in a popover. Values are ISO date strings.
Source
The component’s own files, exactly as they run on this site.
/** * Popover on the native popover API: it sits in the top layer (never clipped by a scrolling * panel), closes on Esc and on a click outside, and keeps inheriting the theme from where it * is written in the tree. Script only places it next to its anchor and flips it when there is * no room below. * * const pop = usePopover() * <button ref={pop.anchorRef} onClick={pop.toggle} aria-expanded={pop.open} aria-haspopup="dialog">Due date</button> * <Popover {...pop.props} label="Choose a due date">…</Popover> * * A date picker dialog is modal in the APG pattern: pass `trapFocus` and Tab / Shift+Tab wrap * between its first and last controls instead of leaving it open behind the page. Without it the * popover is non-modal and closes when focus moves on to something outside it and its trigger. */import { useCallback, useEffect, useLayoutEffect, useRef, useState,