Skip to main content

Popover

Popover displays a floating panel on click or hover to surface additional content, forms, or actions. It wraps the Spar headless Popover primitive and adds Takeoff visual vocabulary — variant, close button, and arrow slots.

Usage​

import { Popover } from '@takeoff-ui/react-spar';
<Popover>
<Popover.Trigger />
<Popover.Content>
<Popover.Header />
<Popover.Description />
<Popover.Arrow />
<Popover.Close />
</Popover.Content>
</Popover>

Playground​


function PlaygroundDemo() {
  return (
    <Popover>
      <Popover.Trigger as={Button}>Click me</Popover.Trigger>
      <Popover.Content>
        <Popover.Header>Title</Popover.Header>
        <Popover.Description>Popover content goes here.</Popover.Description>
      </Popover.Content>
    </Popover>
  );
}

render(<PlaygroundDemo />);

Header & Description​

Use Popover.Header and Popover.Description to structure the content with a title and supporting text. Both are styling slots — no behavior is attached.


function HeaderDescriptionDemo() {
  return (
    <Popover>
      <Popover.Trigger as={Button}>Open</Popover.Trigger>
      <Popover.Content>
        <Popover.Header>Account settings</Popover.Header>
        <Popover.Description>Manage your personal information, password, and notifications.</Popover.Description>
      </Popover.Content>
    </Popover>
  );
}

render(<HeaderDescriptionDemo />);

Variants​

The variant prop controls the visual appearance of the popover content.


function TypesDemo() {
  return (
    <div className="flex flex-wrap justify-center gap-3">
      <Popover>
        <Popover.Trigger as={Button}>White</Popover.Trigger>
        <Popover.Content variant="white">
          <Popover.Description>White variant popover</Popover.Description>
        </Popover.Content>
      </Popover>
      <Popover>
        <Popover.Trigger as={Button}>Dark</Popover.Trigger>
        <Popover.Content variant="dark">
          <Popover.Description>Dark variant popover</Popover.Description>
        </Popover.Content>
      </Popover>
      <Popover>
        <Popover.Trigger as={Button}>Info</Popover.Trigger>
        <Popover.Content variant="info">
          <Popover.Description>Info variant popover</Popover.Description>
        </Popover.Content>
      </Popover>
      <Popover>
        <Popover.Trigger as={Button}>Success</Popover.Trigger>
        <Popover.Content variant="success">
          <Popover.Description>Success variant popover</Popover.Description>
        </Popover.Content>
      </Popover>
      <Popover>
        <Popover.Trigger as={Button}>Warning</Popover.Trigger>
        <Popover.Content variant="warning">
          <Popover.Description>Warning variant popover</Popover.Description>
        </Popover.Content>
      </Popover>
      <Popover>
        <Popover.Trigger as={Button}>Danger</Popover.Trigger>
        <Popover.Content variant="danger">
          <Popover.Description>Danger variant popover</Popover.Description>
        </Popover.Content>
      </Popover>
      <Popover>
        <Popover.Trigger as={Button}>Neutral</Popover.Trigger>
        <Popover.Content variant="neutral">
          <Popover.Description>Neutral variant popover</Popover.Description>
        </Popover.Content>
      </Popover>
    </div>
  );
}

render(<TypesDemo />);

Placement​


function PlacementDemo() {
  return (
    <div className="flex flex-wrap justify-center gap-3">
      <Popover>
        <Popover.Trigger as={Button}>Top</Popover.Trigger>
        <Popover.Content side="top">
          <Popover.Description>Positioned top</Popover.Description>
        </Popover.Content>
      </Popover>
      <Popover>
        <Popover.Trigger as={Button}>Bottom</Popover.Trigger>
        <Popover.Content side="bottom">
          <Popover.Description>Positioned bottom</Popover.Description>
        </Popover.Content>
      </Popover>
      <Popover>
        <Popover.Trigger as={Button}>Left</Popover.Trigger>
        <Popover.Content side="left">
          <Popover.Description>Positioned left</Popover.Description>
        </Popover.Content>
      </Popover>
      <Popover>
        <Popover.Trigger as={Button}>Right</Popover.Trigger>
        <Popover.Content side="right">
          <Popover.Description>Positioned right</Popover.Description>
        </Popover.Content>
      </Popover>
    </div>
  );
}

render(<PlacementDemo />);

Arrow​

Add Popover.Arrow as the last child of Popover.Content to point at the trigger. The arrow is bordered by default — its two outer edges continue the content's outline in the variant's border color, and the neck stays open where it joins the bubble. This is matched automatically for every variant and placement.


function ArrowDemo() {
  return (
    <Popover>
      <Popover.Trigger as={Button}>With arrow</Popover.Trigger>
      <Popover.Content>
        <Popover.Description>Popover with arrow</Popover.Description>
        <Popover.Arrow />
      </Popover.Content>
    </Popover>
  );
}

render(<ArrowDemo />);

Pass your own children to Popover.Arrow to replace the default shape (and its border). The default draws its border with the .tk-arrow-border layer and its fill with .tk-arrow-fill — target those classes to restyle it.

Scrollable content​

For long content, put max-height and overflow on an inner wrapper and keep Popover.Arrow a direct child of Popover.Content. Applying overflow to Popover.Content itself would clip the arrow, because the arrow is positioned just outside the content box.


function ScrollableDemo() {
  return (
    <Popover>
      <Popover.Trigger as={Button}>Release notes</Popover.Trigger>
      <Popover.Content className="w-72">
        {/* Arrow is a direct child of Content (overflow: visible) so it is never clipped */}
        <Popover.Arrow />
        <Popover.Header>Release notes</Popover.Header>
        {/* Bound the scroll on an INNER wrapper — never on Content — or the arrow gets cut off */}
        <div className="max-h-40 space-y-2 overflow-y-auto pr-2">
          <Popover.Description>v2.4.0 — Scrollable content with the arrow kept outside the scroll region.</Popover.Description>
          <Popover.Description>v2.3.0 — Dark, info, success, warning, and danger variants.</Popover.Description>
          <Popover.Description>v2.2.0 — New Popover.Header and Popover.Description slots.</Popover.Description>
          <Popover.Description>v2.1.0 — Placement controls: top, bottom, left, and right.</Popover.Description>
          <Popover.Description>v2.0.0 — Rebuilt on Spar's headless Popover primitive.</Popover.Description>
          <Popover.Description>v1.9.0 — Controlled open state via open / onOpenChange.</Popover.Description>
          <Popover.Description>v1.8.0 — Focus trap and escape-to-dismiss.</Popover.Description>
        </div>
      </Popover.Content>
    </Popover>
  );
}

render(<ScrollableDemo />);

Close Button​


function CloseDemo() {
  return (
    <Popover>
      <Popover.Trigger as={Button}>Open</Popover.Trigger>
      <Popover.Content>
          <Popover.Header className="flex items-center justify-between">
          <span>Title</span>
          <Popover.Close />
        </Popover.Header>
        <Popover.Description>Click x to dismiss</Popover.Description>
      </Popover.Content>
    </Popover>
  );
}

render(<CloseDemo />);

Controlled​


function ControlledDemo() {
  const [open, setOpen] = React.useState(false);

  return (
    <div className="flex flex-wrap gap-3">
      <Popover open={open} onOpenChange={setOpen}>
        <Popover.Trigger as={Button}>Controlled</Popover.Trigger>
        <Popover.Content>
          <Popover.Description>Controlled popover</Popover.Description>
        </Popover.Content>
      </Popover>
      <Button onClick={() => setOpen(!open)}>{open ? 'Hide' : 'Show'}</Button>
    </div>
  );
}

render(<ControlledDemo />);

Accessibility & Keyboard​

  • Trigger uses aria-expanded, aria-controls pointing to the content and aria-haspopup="dialog".
  • Non-modal content is a plain focusable container with no role; label it yourself (aria-label / aria-labelledby) when it needs a name. With modal, the content gets role="dialog" and aria-modal="true".
  • Focus is trapped inside the content when modal is true (or trapFocus is set on Popover.Content). No backdrop is rendered.
  • Escape key dismisses the popover and returns focus to the trigger.
  • Pointer down outside the popover dismisses it in both modal and non-modal mode. Moving focus outside dismisses it only when focus is not trapped.
KeyBehavior
EscapeDismiss the popover.
TabNavigate focusable content.
EnterActivate trigger / close button.

API Reference​

Popover​

See Spar Popover docs for primitive behavior.

Props​

NameTypeDefaultDescription
childrenReact.ReactNode-PopoverTrigger and PopoverContent components
idstring-Base id for ARIA wiring. When omitted one is generated. Only the content derives an id from it (${id}-content); the trigger receives no id and points at the content through aria-controls.
modalbooleanfalseModal mode: the open content gets role="dialog" + aria-modal="true" and focus is trapped inside it (same as trapFocus on Popover.Content). No backdrop is rendered. A pointer down outside still dismisses the popover; because focus cannot leave, focus-outside dismissal does not apply.
disabledbooleanfalseDisables every Popover.Trigger (native disabled, click ignored) so the user cannot open the popover. It does not lock the state: defaultOpen / open still show the content, and the render-prop open() / toggle() still open it and call onOpenChange(true).
openboolean-Controlled state for popover visibility
defaultOpenbooleanfalseInitial open state for uncontrolled mode

Events​

NameTypeDefaultDescription
onOpenChange(open: boolean) => void-Callback when popover open state changes

Popover.Content​

Props​

NameTypeDefaultDescription
childrenReact.ReactNode-
variantPopoverVariant'white'Color variant.
classNamesPartial<Record<"root", string>>-Per-slot extra classes.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML-attribute overrides.
containerHTMLElement | nulldocument.bodyPortal container element. Content is portaled to document.body by default.
trapFocusbooleanfalseWhether to trap focus within content
sideSide'bottom'Side of trigger to position against
alignAlign'center'Alignment relative to trigger
classNamestring-

Events​

NameTypeDefaultDescription
onOpenAutoFocus(event: Event) => void-Called when the popover opens, right before focus moves inside (to the first focusable element, else the content itself). Receives a cancelable openautofocus event; call preventDefault() to skip the auto-focus and leave focus where it is.
onFocusOutside(event: FocusEvent) => void-Called when focus moves outside the content and trigger (non-modal, non-trapped popovers only). Receives a cancelable focusoutside FocusEvent dispatched on the newly focused element (native focusin is not cancelable); event.target is the element that received focus. Call preventDefault() to keep the popover open.
onInteractOutside(event: PointerEvent | FocusEvent) => void-Called for any outside interaction, after onPointerDownOutside or onFocusOutside, with the pointerdown event or the cancelable focusoutside FocusEvent described on onFocusOutside. Call preventDefault() on either to keep the popover open.
onCloseAutoFocus(event: Event) => void-Called when popover closes and focus returns to trigger
onEscapeKeyDown(event: KeyboardEvent) => void-Called when escape is pressed
onPointerDownOutside(event: PointerEvent) => void-Called when pointer down occurs outside the content

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-variantAlwaysReflects the resolved variant for theme recipe scoping.

Popover.Trigger​

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.

Popover.Arrow​

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for the arrow.

Popover.Close​

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for the close button.

Type Definitions​

NameDefinition
PopoverVariant'white' | 'dark' | 'info' | 'success' | 'warning' | 'danger' | 'neutral'
Side'top' | 'right' | 'bottom' | 'left'
Align'start' | 'center' | 'end'
PopoverTriggerRenderProps{ isOpen: boolean; disabled: boolean; open: () => void; close: () => void; toggle: () => void }
PopoverCloseRenderProps{ isOpen: boolean; close: () => void }