Components/Overlay
Popover
Popovers are small panels anchored to the control that opens them, holding content or controls the page has no room to keep on show. The panel floats over what is underneath without taking it, so the reader can dismiss it and carry straight on with whatever they were doing.
import { Popover } from '@growth-protocol-ai/alpha';When to use
Reach for a popover when something is worth a panel but not worth the page: a set of filters, a handful of controls, a summary of the row a reader is pointing at. It opens from the control that asks for it and closes the moment attention moves, so the page underneath keeps its place and there is nothing to navigate back from. What most often goes wrong is scope — a panel that grows a second step, or one holding a decision that must not be abandoned halfway, has outgrown the surface rather than found a use for it.
When not to use
A list of choices that sets a value is a Dropdown, which already owns the keyboard path through it and the way a selection reads back on the trigger. A single line clarifying a control, with nothing in it to click, is a Tooltip. And anything that has to be dealt with before the reader can get on with anything else is a Modal, because a popover deliberately leaves the page live and lets a stray click throw the panel away — which is exactly wrong for a question with consequences. The test for that last one is whether losing what is in the panel would matter.
side4 values
Side is where the panel sits, and it is a preference rather than a guarantee: a trigger with no room below flips to above, and the panel slides along its side rather than off the edge of the window. Narrow the window with one open and it will do exactly that. Every specimen here is closed until you press it, which is the component answering for itself how much of the page it is willing to cover.
| side | When to use |
|---|---|
top | Above the trigger. For a control near the foot of the page, where below is off the edge and the flip would happen anyway. |
right | To the right. For a trigger in a left rail or the first cell of a row, where the panel would otherwise cover the list it came from. |
bottom | Below the trigger. The default, and the one to leave alone — a panel under the control that opened it is where a reader looks for it. |
left | To the left. For a control in a right rail, for the same reason. |
align3 values
Alignment is where the panel sits along its side, and it carries more weight here than it does for a hint: a panel is usually wider than the control that opened it, so centring one on a narrow button hangs it over whatever is either side. Collision handling still has the last word — a panel with no room to align the way it was asked slides until it fits.
| align | When to use |
|---|---|
start | Flush with the start of the trigger. The answer whenever the panel is wider than what opened it, and for anything in the first column of a row. |
center | Centred on the trigger. The default, and right while the two are a similar width. |
end | Flush with the end of the trigger. The mirror of start, for a control at the right-hand end of its row. |
width2 values
Width says whether the panel is a surface of its own or an extension of the control beneath it. It is the one axis here Alpha decides rather than the geometry, and the decision is about what the panel is for rather than how much is in it.
| width | When to use |
|---|---|
auto | Sized to its content, between a floor and a cap, so a panel with two controls in it is not as wide as the button that opened it. |
trigger | Exactly the trigger’s width, the way a dropdown panel is. For a panel that reads as part of the control — a filter set under a full-width button, or a picker under a field. |
| State | When to use |
|---|---|
Closed | Closed, the panel is not hidden — it is unmounted. Nothing in it is in the document, so nothing in it can be found by search, reached by a screen reader or tabbed into, and a panel is the wrong home for anything that has to survive being closed. The trigger is all there is. |
Open | Open, the panel is portalled to the end of the document and painted at the modal’s layer. Focus moves into it, the page behind stays live and takes the pointer, and Tab walks out of the end of the panel — which closes it, because a popover holds nothing in place. |
Scrolling | Past the room its side leaves, the panel scrolls, and it scrolls as one: the heading travels with the content rather than staying put. That is the honest limit of the surface — a panel with enough in it to want a header that stays is a modal that has not admitted it yet. |
Escape closes it, a click outside closes it, and Tab leaving the end of the panel closes it. All three report through onOpenChange, so a controlled popover that ignores the event cannot be dismissed at all. There is no way to opt out of Escape, and there should not be.
Focus moves into the panel when it opens and returns to the trigger when it closes. Nothing is trapped and the page behind stays live, which is the whole difference from a modal — so anything a reader must not wander away from half-done does not belong in here.
The panel is a dialog, so it needs a name. Setting title gives it one and draws the heading at the same time, which is why the two cannot disagree; a panel with no heading takes an aria-label instead, and a panel with neither is announced as nothing at all.
A list of choices that sets a value is a Dropdown, not a popover with buttons in it. The keyboard path through a list, the way a selection reads back on the trigger and the multi-select behaviour are all there already, and a version built by hand inside a panel will be a worse one.
A single line clarifying a control is a Tooltip. A popover costs a click to open and a dismissal to leave, which is a great deal of ceremony for something a reader only has to glance at — and it takes the pointer, so it will sit in the way of what it is describing.
One panel, one job. It scrolls as one when there is more in it than fits, heading included, and it cannot grow a second step or a footer that stays put — so a flow, a confirmation, or anything worth abandoning halfway is a Modal.
The trigger has to be a real control. It takes the ref, the open state and the ARIA wiring the primitive puts on it, so a bare div is both unreachable by keyboard and unannounced as anything; whatever is clickable enough to raise a panel should have been a button already.
Reach for the trigger width when the panel is an extension of the control — a filter set under a full-width button, a picker under a field. A panel that is its own surface should be sized by what is in it, because pinning one to a narrow trigger is how a two-word label ends up on four lines.
Holding the open state yourself
Hold the open state and the panel follows what you say rather than what the trigger says, which is what closing it from the inside takes: there is no close part and no close button, so a button in the panel that finishes a job sets the state itself. Every route out still reports through onOpenChange, so a controlled panel that drops the event is one Escape cannot dismiss.
Opened from inside a dialog
A popover raised from inside a dialog is painted over it rather than under it, because the two share a stacking level. Nothing has to be passed in to arrange that. It is worth knowing in the other direction as well: the panel is the topmost layer, so Escape closes it and leaves the dialog up, and the second press is what closes the dialog.
| Prop | Type | Default | Notes |
|---|---|---|---|
trigger | ReactNode | — | Required. The control that opens the panel. Exactly one element, and it has to forward a ref and take the props the primitive puts on it — every Alpha control does. |
children | ReactNode | — | Required. What is in the panel: content, controls, or a composition of both. It arrives as children rather than as a prop because a panel holding a single node is the exception here, and the trigger is the side that is always one element. |
title | string | — | The heading at the top of the panel, and what names the panel to a screen reader. A string rather than a node, because it is the accessible name as much as the visible one. Leave it off and the panel needs an aria-label instead. |
width | "auto" | "trigger" | "auto" | Whether the panel is sized by what is in it or by the control that opened it. See the width table above. |
side | "top" | "right" | "bottom" | "left" | "bottom" | Where it sits, before collision. Flips to the opposite side when there is no room. |
align | "start" | "center" | "end" | "center" | Where it sits along that side. Slides to stay in the window rather than overflowing it. |
sideOffset | number | 8 | Gap between the trigger and the panel’s nearest edge. Wider than a dropdown’s, because this panel is a surface over the page rather than a continuation of the control. |
collisionPadding | number | Partial<Record<PopoverSide, number>> | 8 | How close to the edge of the window the panel may come before it moves. A number pads every edge; an object pads the ones it names. |
open | boolean | — | Controlled open state. Pass it and the trigger no longer decides, so pair it with onOpenChange or nothing will close. |
defaultOpen | boolean | false | Open on mount, when uncontrolled. For a panel that has to be seen once without the state leaving the component. |
onOpenChange | (open: boolean) => void | — | Fires for every route in and out: the trigger, Escape, a click outside the panel, and Tab leaving the end of it. |
popover | [data-slot] | — | The panel itself, portalled to the end of the document rather than nested in the trigger — so it is reached from the root of your stylesheet and not by descent, and this attribute with the width mirrored beside it is the whole of the handle on it. |
popover-title | [data-slot] | — | The heading row, in the document only while title is set. It sits inside the scroll port with everything else, so a rule here can restyle it but cannot pin it. |