Skip to content

Popover

A small raised panel next to its trigger, for a short form, a choice from a list or details, that closes on Escape or a press outside.

import { Button, Popover, Portal } from '@contour-ui/react';

<Popover.Root>
  <Popover.Trigger asChild>
    <Button variant="outline">Share</Button>
  </Popover.Trigger>
  <Portal>
    <Popover.Positioner>
      <Popover.Content>
        <Popover.Title>Share this page</Popover.Title>
        <Popover.Description>Anyone with the link can view it.</Popover.Description>
        <Button size="sm" onClick={copyLink}>
          Copy link
        </Button>
        <Popover.CloseTrigger />
      </Popover.Content>
    </Popover.Positioner>
  </Portal>
</Popover.Root>;

Popover is the Headless Popover with the look of contour-ui: a raised panel with a border, a shadow and the corners of a panel, next to its trigger. It grows in from the edge next to the trigger when it opens and shrinks back when it closes. Focus moves into it as it opens; Escape, a press outside it, Tab past its end and its close triggers close it, and focus goes back to the trigger.

The parts

  • The trigger. Popover.Trigger has no look of its own: pass a Button or an IconButton with asChild. It opens the popover and closes it again.
  • The overlay. Portal renders the positioner at the end of the page, above everything else, and the positioner holds the content next to the trigger, or next to Popover.Anchor when there is one.
  • The content. Popover.Content stacks what it holds: Popover.Title, in the heading style, Popover.Description, in a muted color, and anything else, such as actions or a list.
  • The x. Popover.CloseTrigger is a small ghost x labeled "Close" in the top end corner; the title, or a description without a title, makes room for it. Put it after the rest of the content, so that focus goes to the content first. With asChild, a button of your own closes the popover instead, such as Copy link above.

Placement

The content opens below the trigger, at its start edge, 8px away. When that side has no room, it flips to the other one, and it slides along its side to stay 8px inside the viewport. Set another side with positioning:

<Popover.Root positioning={{ placement: 'top' }}>

placement takes top, right, bottom and left, centered or with -start and -end; offset is the gap to the trigger, and sameWidth makes the content as wide as its trigger. The other options are on the Headless page.

Size

The content is as wide as what it holds, up to 320px (--ct-size-popover), and as wide as its trigger with sameWidth. It is never higher than the room it has between its trigger and the edge of the viewport: past that, it scrolls, and opens scrolled to the element it focuses. For another greatest width, set the token on the content, with a class of your own:

.wide-popover {
  --ct-size-popover: 24rem;
}

A choice from a list

A Listbox fits in a popover as it is, with no frame of its own. Here the owner keeps open in its state and closes the popover when an item is selected:

const [open, setOpen] = useState(false);

<Popover.Root open={open} onOpenChange={(details) => setOpen(details.open)}>
  <Popover.Trigger asChild>
    <Button variant="outline">Waveform</Button>
  </Popover.Trigger>
  <Portal>
    <Popover.Positioner>
      <Popover.Content aria-label="Waveform">
        <Listbox.Root
          collection={waveforms}
          value={[waveform]}
          onSelect={(details) => {
            setWaveform(details.value);
            setOpen(false);
          }}
        >
          …
        </Listbox.Root>
      </Popover.Content>
    </Popover.Positioner>
  </Portal>
</Popover.Root>;

Open and close

defaultOpen opens a popover that keeps its own state; open and onOpenChange keep it in yours, as above. closeOnEscape={false} and closeOnInteractOutside={false} keep it open on Escape and on a press outside. modal keeps the user inside it: Tab goes round the content, the page does not scroll, and the rest of the page is out of reach until it closes.

Anatomy

PartElementClassAttributes
Popover.Rootnone—renders only its children
Popover.Triggerbuttoncontour-popover__triggerdata-part="trigger", aria-haspopup="dialog", aria-expanded, aria-controls, data-state
Popover.Anchordivcontour-popover__anchordata-part="anchor"
Popover.Positionerdivcontour-popover__positionerdata-part="positioner", data-state, data-placement, hidden (closed)
Popover.Contentdivcontour-popover__contentdata-part="content", role="dialog", aria-labelledby, aria-describedby, aria-modal, data-state, data-placement
Popover.Titledivcontour-popover__titledata-part="title", id
Popover.Descriptiondivcontour-popover__descriptiondata-part="description", id
Popover.CloseTriggerbuttoncontour-popover__close-triggerdata-part="close-trigger", a CloseButton; with asChild, your element

Every part carries data-scope="popover". The positioner gets its position, --layer-index and the positioning variables (--reference-width, --available-height, --transform-origin…) from the platform, in its inline style.

Props

Popover.Root:

PropTypeDefault
openboolean—
defaultOpenbooleanfalse
onOpenChange(details: { open: boolean }) => void—
positioningplacement, offset, flip, shift, sameWidth, fitViewport, strategy{ placement: 'bottom-start', offset: 8, shift: 8, fitViewport: true }
modalbooleanfalse
portalledboolean: the positioner is in a Portaltrue
autoFocusboolean: focus moves in when it openstrue
initialFocusEl() => HTMLElement | null—
restoreFocusboolean: focus moves back when it closestrue
finalFocusEl() => HTMLElement | null—
closeOnEscapebooleantrue
closeOnInteractOutsideboolean: a press outside or focus moving outtrue
lazyMountbooleanfalse
unmountOnExitbooleanfalse
onExitComplete() => void—

Popover.CloseTrigger takes the props of CloseButton (label, variant, size, sm by default) for the x, or asChild and your own element. Every other part takes asChild and the props of its element, such as className, style and ref; className is merged with the part's class.

Keyboard

KeyAction
Enter, SpaceOn the trigger, opens or closes the popover
EscapeCloses the top-most popover and moves focus back to its trigger
TabThrough the content; past its end, on to what follows the trigger, which closes it
Shift + TabFrom the first element of the content back to the trigger
Tab, Shift + Tab (modal)Go round the content

Accessibility

  • A dialog. The content has the dialog role and is named by Popover.Title; without a title, give it an aria-label, as the list above does. Popover.Description describes it.
  • The trigger tells that it opens a dialog, whether it is open, and which element it controls.
  • The x is named "Close"; name what it closes when several things can.
  • Focus moves into the content as it opens, and back as it closes. A popover open on the first render leaves focus where it is.
  • Modal popovers set aria-modal="true" and make the rest of the page inert.

Tokens

TokenUsed for
--ct-color-bg-raisedthe background of the content
--ct-color-border-default, --ct-border-width-defaultits border
--ct-radius-panelits corners
--ct-shadow-raisedits shadow
--ct-size-popoverits greatest width, unless its trigger is wider
--ct-space-panel-insetits padding
--ct-space-group-gapthe gap between its parts, the inset of the x
--ct-color-fg-default, --ct-text-bodyits text
--ct-text-headingthe title
--ct-color-fg-mutedthe description
--ct-size-control-sm, --ct-space-control-gapthe x, and the room the title leaves for it
--ct-layer-overlaythe stacking of the positioner, plus --layer-index
--ct-motion-enter, --ct-motion-exitgrowing in and shrinking out