Popover
A panel anchored to its trigger, on the platform's own Popover API: the top layer, light dismiss and focus return are the browser's, so there is no portal and no z-index to manage.
Import
JSX
import Popover from '@box-kite/react/components/popover';Usage
Nothing was measured to put this here.
side="end" is the inline axis, so this mirrors in a right-to-left page.JSX
<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}>
<Checkbox label="Only mine" />
</Popover>There is no portal
A popover is in the browser's top layer: it paints above every stacking context and outside every clipped ancestor, which is the entire class of bug a portal exists to work around. Measured in Chrome 152 against a sibling with
z-index: 9999, a transformed ancestor and an overflow: hidden one — the panel wins all three, and position: fixed loses two of them.Because nothing is moved, the panel keeps everything it inherits where it was declared: the theme around it, the custom properties, the text direction, and its place in the tab order — trigger, then panel, then the rest of the page. A portalled layer has none of that by default, which is why
Overlay has to carry its direction across by hand.JSX
// Inside a clipped, transformed, z-indexed box — and it still escapes all three.
<Box overflow="hidden" translateX={1} position="relative" zIndex={1} height={20}>
<Popover label="Escapes" trigger={(t) => <Button {...t}>Open</Button>}>
<Box>Painted above the page, clipped by nothing.</Box>
</Popover>
</Box>Clipped by nothing.
The panel is always rendered
Closed means
display: none from the browser's own stylesheet, not unmounted. That is what lets the browser own showing and hiding — and it is what makes the exit a plain CSS transition instead of a <Presence>, since nothing ever leaves the DOM to be held back. The entrance is startingStyle and the exit is one prop, transitionBehavior="allow-discrete", both already in the component's styles.The cost is that the children render whether or not anyone has opened it. For a panel that is expensive to build, gate it yourself:
JSX
const [open, setOpen] = useState(false);
<Popover label="Report" open={open} onOpenChange={setOpen} trigger={(t) => <Button {...t}>Report</Button>}>
{open ? <Box>The expensive part</Box> : null}
</Popover>Controlled, and why a close cannot be refused
Leave
open out and the popover owns its state. Pass it and you own it — with one asymmetry that comes from the platform: the browser's beforetoggle event is cancelable when it opens and not when it closes. So a controlled popover can refuse to open, but a light dismiss has already happened by the time you hear about it. Keep open true and the component shows it again rather than arguing.JSX
<Popover
label="Filters"
onOpenChange={(open, { reason }) => console.log(open, reason)}
trigger={(t) => <Button {...t}>Filters</Button>}
>
<Checkbox label="Only mine" />
</Popover>Press Escape, or click away.
Last reason:
—Focus, and the two things the platform does not do
Opening moves focus into the panel
The Popover API moves focus nowhere unless something inside carries
autofocus. APG's dialog wants focus in the panel, so the component puts it there — on the panel itself, which is focusable by script and never by Tab. An autofocus inside still wins, and autoFocus={false} turns the whole thing off.Closing returns focus — unless React got there first
The platform returns focus to the trigger when a popover is hidden, and does nothing at all when the element is removed while open — focus falls to
<body>, which is the commonest keyboard bug in a popup. A consumer closing a controlled popover removes it, so useFocusReturn covers exactly that gap.Nothing is trapped
This is a non-modal dialog: Tab leaves the panel for the rest of the page, and because the panel was never moved, "the rest of the page" is what follows the trigger. A modal that holds focus is
<dialog>, and it is a different component.The trigger is a render prop, and it has to be a button
The component hands you a
ref and a bag of attributes rather than cloning a child, for the reason Tooltip does: Box takes DOM attributes in a props bag and a plain <button> takes them on top, and guessing wrong costs the wiring the pattern is for.It must be a button. The toggle is the platform's own
popovertarget, which the browser reads off a button and nothing else — and handing it over is what fixes the trap every hand-rolled popover falls into: light dismiss closes on pointerdown, so a click handler of your own runs afterwards, reads "closed", and opens it straight back up. Pressing the trigger of an open popover would never close it.JSX
// A Box component — ref and props are both top-level Box props, so one spread does it.
<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}>
<Checkbox label="Only mine" />
</Popover>
// A plain element — the same two pieces, named.
<Popover label="Filters" trigger={(t) => <button ref={t.ref} {...t.props}>Filters</button>}>
<Checkbox label="Only mine" />
</Popover>Where the browser has no Popover API
The Popover API is Baseline and widely available; anchor positioning is not, and neither is universal. Where either is missing the panel falls back to a portalled
Overlay with useDismiss and useFocusReturn supplying what the platform would have. The API is identical and so is the styling; what you lose is what the portal costs, which is the tab order and a local theme following the markup. That is the older browser's price rather than a choice.Styling
The panel is a Box, so every prop is available on it, and the defaults live in
Box.components('popover') — including the reset for the border, padding and system colours the browser's own [popover] rules put underneath it.JSX
<Popover label="Wide" p={6} maxWidth={100} borderRadius={4} shadow="large" trigger={(t) => <Button {...t}>Open</Button>}>
<Box>Any Box prop, including a theme.</Box>
</Popover>Popover props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
triggerrequired | (trigger: PopoverTrigger) => React.ReactNode | — | The trigger, handed the ref and props that wire it to the popover. It has to be a button. |
children | React.ReactNode | — | The panel's content. Rendered whether or not the popover is open — see the note on mounting. |
open | boolean | — | Controlled open state. Leave it out and the popover owns it. |
defaultOpen | boolean | false | Whether it starts open, when the popover owns its own state. |
onOpenChange | ChangeHandler<boolean, PopoverReason> | — | Fires with the new state and why it changed — 'trigger', 'escape', 'outside-pointer' or 'imperative'. A close cannot be refused: the browser has already done it by the time this runs. |
label | string | — | The panel's accessible name. role="dialog" has none of its own, and a dialog without one is unusable. |
labelledBy | string | — | Names the panel after an element already on the page, instead of label. |
side | AnchorSide | 'bottom' | Which side of the trigger the panel sits on — top/bottom are the block axis, start/end the inline one, so a panel beside its trigger mirrors in a right-to-left page. Default 'bottom'. |
align | AnchorAlign | 'center' | Which of the trigger's edges to line the panel up with along the other axis. Default 'center'. |
offset | number | 2 | The gap between trigger and panel, on the ÷4 spacing scale. Default 2 — 8px. |
flip | boolean | true | Whether a side with no room may be swapped for its opposite. Default true. |
matchWidth | boolean | false | Whether the panel is at least as wide as its trigger. Default false — a panel sizes to its content. |
autoFocus | boolean | true | Whether opening moves focus into the panel. Default true, which is what APG asks of a dialog. An autofocus attribute inside wins: the browser applies it first and this only acts on what is left. |
Popover keyboard
| Key | Result |
|---|---|
Enter, Space | On the trigger, toggles the panel. |
Escape | Closes it and returns focus to the trigger, from anywhere inside. |
Tab | Moves into the panel from the trigger, and out of the panel to whatever follows it. |
Popover accessibility
role="dialog"on the panel, named bylabelorlabelledBy; the popover attribute supplies no role at all, and a panel with none is announced as a group of orphaned content.- The trigger carries
aria-expanded,aria-haspopup="dialog"andaria-controls, so the control says what it operates and what state it is in. - Focus moves into the panel on open and back to the trigger on close. Nothing is trapped: this is a non-modal dialog, so Tab leaves it, and the panel follows the trigger in the DOM either way.
Swept with axe on every release, in this state:
Popover (open). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.Popover style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.popovervariants: topLayer