Overlay

Popover

A panel anchored to a trigger. This is the overlay mechanism the rest of the library copies — the menu, the tooltip and the select each carry their own copy of it, on purpose.

Usage

import { Popover } from '@carabine/ui/popover';
<Popover width={240} content={<Settings />}>
  <Button variant="secondary">Options</Button>
</Popover>

It grows out of the edge it lands on. transform-origin at the trigger's corner is the difference between a panel that came from the button and one that was switched on nearby.

Placement

Three steps, in order. It tries the side it was asked for; it flips to the opposite side only when that side genuinely has more room, rather than the moment the first one is tight; and then it slides along the other axis to stay inside the window, keeping at least offset from every edge.

The spike is cut into the panel's outline rather than drawn as a rotated square behind it — one SVG path for the whole shape, notch included. A rotated square needs its own border, its own shadow and its own background to match the panel's, and on glass it never does.

Examples

Every side

{(['top', 'right', 'bottom', 'left'] as const).map((side) => (
  <Popover key={side} placement={side} content={<p>Grown from the {side} edge.</p>}>
    <Button variant="secondary">{side}</Button>
  </Popover>
))}

Customization

<Popover
  placement="right"
  align="start"
  gap={8}          // between the trigger and the panel
  offset={12}      // smallest distance kept from the window's edges
  width={280}      // left off, the panel is as wide as its content
  padding={16}
  radius={16}
  arrow={false}    // no spike
  arrowSize={14}   // base width; it protrudes by half of this
  theme="light"
/>

place() and shape()

Both are exported, because the arithmetic is useful on its own — an anchored menu of your own, a custom overlay, anything that has a box and needs somewhere to put another one.

import { place, origin, shape } from '@carabine/ui/popover';

place({
  anchor: { left, top, width, height },
  panel: { width, height },
  viewport: { width, height },
  side: 'bottom',
  align: 'center',
  gap: 8,
  offset: 12,
}); // → { left, top, side }

origin('bottom', 'center'); // → the matching transform-origin
shape({ width: 200, height: 120, radius: 16, side: 'top', arrowSize: 14, arrowAt: 100 });

There are four copies of this arithmetic in the library and that is deliberate. The fourth copy is cheaper than the edge that would let one component's placement bug become four components' regression.

Styling reference

A single-layer panel is a core, not a shell. It was built as a shell first, and the result was that you could see the page through a popover twice as much as through a toast — 60% against an effective 94%, on two surfaces meant to be the same material. Whichever layer the text lands on carries the core's weight.

API reference

PropType
classNamestringAdded to the panel’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Both land on the panel. The trigger is already your element — you style it where you write it — and the panel is the part that portals away from your markup and is otherwise out of reach. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
childrenReactNodeThe trigger
contentReactNodeWhat the panel holds
openbooleanControlled
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void
placementSide'bottom'Preferred side; flips only if the other is better
alignAlign'center'Where it sits along that side
gapnumber8Between the trigger and the panel
offsetnumber12Smallest distance kept from the viewport’s edges
widthnumberLeft off, the panel is as wide as its content
paddingnumber12Inside. The spike’s depth is added on its edge
radiusnumber16Corner radius, and how close the spike can get to one
arrowbooleantrueThe spike pointing at the trigger
arrowSizenumber14Base width. It protrudes by half of this
zIndexnumber50
containerHTMLElement | nulldocument.bodyWhere it portals to
stiffnessnumber420
dampingnumber34
openScalenumber0.94Scale the panel grows from
labelstring'Popover'Accessible name of the panel
theme'dark' | 'light''dark'