Popover

Anchored floating panel with outside-click and Escape dismissal.

organismsSource
import { Popover, PopoverContent, PopoverTrigger } from '@elirobinson/react/components/organisms/Popover';

Styles: @elirobinson/react/styles/organisms/Popover.css — already included when you import @elirobinson/react/styles.css.

Show code
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@elirobinson/react/components/organisms/Popover';

export default function Basic() {
  return (
    <Popover>
      <PopoverTrigger className="ds-button ds-button--secondary">
        What&apos;s included?
      </PopoverTrigger>
      <PopoverContent>
        <p>Every coaching guide ships as a versioned PDF plus a printable practice-plan card.</p>
      </PopoverContent>
    </Popover>
  );
}

When to use it

Use Popover for content that's genuinely optional to see — extra detail, a small set of filters, a short form — anchored to whatever triggered it. Nothing about it blocks the page: no focus trap, no modal backdrop, background content stays fully interactive. If dismissing it needs to be unmissable, or it needs to hold focus, that's a Dialog, not a Popover.

Controlled, with a manual dismiss

There's no PopoverClose — unlike Dialog, closing programmatically means calling onOpenChange(false) yourself from inside the content.

Show code
import { useState } from 'react';

import { Button } from '@elirobinson/react/components/atoms/Button';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@elirobinson/react/components/organisms/Popover';

const sports = ['All sports', 'Soccer', 'Basketball', 'Track'];

export default function Controlled() {
  const [open, setOpen] = useState(false);
  const [sport, setSport] = useState('All sports');

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger className="ds-button ds-button--secondary">Filter: {sport}</PopoverTrigger>
      <PopoverContent>
        <div className="demo-col">
          {sports.map((option) => (
            <Button
              key={option}
              variant={option === sport ? 'primary' : 'ghost'}
              onClick={() => setSport(option)}
            >
              {option}
            </Button>
          ))}
          <Button variant="secondary" onClick={() => setOpen(false)}>
            Done
          </Button>
        </div>
      </PopoverContent>
    </Popover>
  );
}

Props

PropTypeDefaultDescription
defaultOpenbooleanfalse
onOpenChange((open: boolean) => void)
openboolean

PopoverContent

PropTypeDefaultDescription
align"start" | "center" | "end"`'start'` lines the panel's left edge up with the trigger's and gives it the trigger's width as a minimum — the menu/listbox shape. `'end'` is the same shape mirrored: the panel's *right* edge is pinned to the trigger's, which is what a trigger near the right edge of the viewport needs. `'center'` centres it on the trigger and leaves the width to the content, which is what a tooltip wants. Default `'start'`.
side"top" | "bottom"Which edge of the trigger the panel hangs from. Default `'bottom'`.

Also accepts all HTMLAttributes<HTMLDivElement> & Pick<AnchoredOverlayContentProps, 'side' | 'align'> props.

PopoverTrigger

No props of its own beyond the inherited HTML attributes.

Also accepts all ButtonHTMLAttributes<HTMLButtonElement> props.

Accessibility

  • PopoverContent renders role="dialog", portaled to document.body. This is a non-modal dialog role: nothing calls showModal(), there's no focus trap, and the rest of the page stays interactive — the opposite of Dialog.
  • Escape closes it from anywhere (a document-level listener, the same useEscapeKey hook DropdownMenu uses), and clicking outside the trigger or content closes it too.
  • No initial-focus management: opening the popover doesn't move DOM focus into PopoverContent. Focus stays wherever it was — typically the trigger, since activating it doesn't blur it. If your content includes interactive elements a fully keyboard-driven flow depends on, move focus there yourself, or rely on the user tabbing in.
  • No built-in close affordance: there's no PopoverClose subcomponent. Closing it from inside the content means calling onOpenChange(false) yourself.
  • Content is positioned with fixed coordinates computed from the trigger's bounding rect, the same anchoring mechanism DropdownMenu uses — recalculated on scroll and resize while the popover is open, so the content follows its trigger.
  • Content that doesn't fit below its trigger flips above it, and content that overruns the right edge pins its right edge to the trigger's instead — the side and align you pass to PopoverContent are preferences, not guarantees. Each axis flips at most once per open and never flips back, so it can't oscillate while you scroll.
  • Content that fits on neither side is shifted rather than shrunk: it keeps its width and slides along the axis until it is back inside the viewport, so it never reflows into whatever space is left beside a pinned edge. It stops being edge-aligned with its trigger, and may overlap it, but it doesn't narrow. Content taller than the viewport takes a max-height of the viewport and scrolls instead, since no offset makes that fit. Unlike DropdownMenu, PopoverContent sets no min-width of its own, so the trigger's width is the only floor — give your content --anchored-min-width if it needs a wider one. See useAnchoredPosition.

Do

  • Use Popover for supplementary, non-blocking content — a hint, a filter panel, a short form.
  • Wire your own dismiss control inside PopoverContent when the content needs an explicit close action.
  • Keep content lightweight — nothing here traps focus, so a long or complex form is a sign you actually want Dialog.

Don't

  • Use it for anything that must block interaction with the rest of the page — reach for Dialog, an actual native modal.
  • Assume focus moves into the content on open — it doesn't; test keyboard flows accordingly.
  • Render PopoverContent or PopoverTrigger outside a Popover — it throws.