# Popover

_Box Kite 2.1.0 · a markdown copy of https://www.box-kite.dev/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 `transform`ed 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](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `trigger`required | `(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 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.

`popover`variants: topLayer

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

