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
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>

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>
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.
PropTypeDefaultWhat 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.
childrenReact.ReactNode—The panel's content. Rendered whether or not the popover is open — see the note on mounting.
openboolean—Controlled open state. Leave it out and the popover owns it.
defaultOpenbooleanfalseWhether it starts open, when the popover owns its own state.
onOpenChangeChangeHandler<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.
labelstring—The panel's accessible name. role="dialog" has none of its own, and a dialog without one is unusable.
labelledBystring—Names the panel after an element already on the page, instead of label.
sideAnchorSide'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'.
alignAnchorAlign'center'Which of the trigger's edges to line the panel up with along the other axis. Default 'center'.
offsetnumber2The gap between trigger and panel, on the ÷4 spacing scale. Default 2 — 8px.
flipbooleantrueWhether a side with no room may be swapped for its opposite. Default true.
matchWidthbooleanfalseWhether the panel is at least as wide as its trigger. Default false — a panel sizes to its content.
autoFocusbooleantrueWhether 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

KeyResult
Enter, SpaceOn the trigger, toggles the panel.
EscapeCloses it and returns focus to the trigger, from anywhere inside.
TabMoves into the panel from the trigger, and out of the panel to whatever follows it.

Popover accessibility

  • role="dialog" on the panel, named by label or labelledBy; 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" and aria-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