Skip to content

Popover

A dialog that opens next to its trigger, with placement that flips to stay in view, dismissal by Escape or a press outside, and focus that moves in and back.

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

<Popover.Root>
  <Popover.Trigger>Share</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>
        <Popover.CloseTrigger>Done</Popover.CloseTrigger>
      </Popover.Content>
    </Popover.Positioner>
  </Portal>
</Popover.Root>;

Popover opens a dialog next to its trigger: a short form, a list of choices, details about something on the page. The trigger above opens it; focus moves to the field, Tab goes on to Done and then out of the popover, and Escape, a press outside or Done closes it and sends focus back to the trigger. The styled Popover is this component with the contour-ui look.

Opening and closing

Popover.Trigger opens the popover and closes it again; Popover.CloseTrigger closes it from the inside. Escape closes it, and so does a press outside it or focus moving out of it, unless closeOnEscape={false} or closeOnInteractOutside={false}. Each request is reported as onOpenChange({ open }); a request for the state the popover is in reports nothing.

defaultOpen opens a popover that keeps its own state on its first render. To keep the state yourself, pass open and onOpenChange: requests are then only reported, and the popover opens or closes when you pass the new open, which is not reported back.

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

<Popover.Root open={open} onOpenChange={(details) => setOpen(details.open)}>
  …
</Popover.Root>;

Inside the root, usePopoverContext() returns the same API, with setOpen(open) and toggle(), for parts of your own.

Placement

Popover.Positioner holds the content and is placed next to the trigger, or next to Popover.Anchor when there is one, such as a field with the trigger at its end. By default the content opens below, at the start edge, 8px away. positioning changes that; the options left out keep these defaults:

OptionTypeDefault
placementtop, right, bottom, left, each -start or -end too'bottom-start'
offsetnumber, or { mainAxis, crossAxis }, in px8
flipboolean, or the placements to try in ordertrue
shiftnumber: the distance kept from the edges of the viewport; false stops sliding8
sameWidthboolean: as wide as the triggerfalse
fitViewportboolean: no larger than the room it hastrue
strategy'absolute', 'fixed''absolute'

The content flips to the other side when its side has no room, and slides along it to stay in view. Its placement after a flip is data-placement on the positioner and on the content.

<Popover.Root positioning={{ placement: 'top', offset: 4, sameWidth: true }}>

Put the positioner in a Portal, as above: it is then rendered at the end of document.body, out of the overflow and the stacking context of the trigger's parents. A popover rendered in place, without a portal, tells it with portalled={false}, so that the Tab order is left as it is.

Focus

  • In. Opening moves focus into the content: to initialFocusEl(), else its first element that takes focus, else the content itself. autoFocus={false} leaves focus where it is. A popover open on its first render (defaultOpen) does not take focus. Focus moves without scrolling, as the content is not in place yet; once it has its first position, and so its size, a content that scrolls is scrolled to the focused element, and nothing outside the content moves.
  • Back. Closing moves focus back to where it was before the popover opened, as a rule the trigger, or to finalFocusEl(). Focus that has moved on to something outside the popover, such as a field pressed outside it, stays there. restoreFocus={false} leaves focus alone.
  • Tab. The content keeps its place in the Tab order right after the trigger, wherever it is rendered: Tab on the trigger moves into it, Tab on its last element moves on to what follows the trigger, which closes the popover, and Shift+Tab on its first element moves back to the trigger.

modal keeps the user inside the popover while it is open: Tab goes round the content, the page does not scroll, and the rest of the page is inert, out of reach of the pointer, the keyboard and screen readers. The trigger stays reachable, so it still closes the popover. The content gets aria-modal="true".

Mounting and animations

The positioner and the content are rendered while closed, hidden; lazyMount renders them only once the popover first opens, and unmountOnExit removes them once it has closed; onExitComplete reports each close. The content carries data-state, open or closed: give the closed state an animation of its own @keyframes, and the popover stays on screen, inert, until it ends, as Presence does.

@keyframes popover-in {
  from {
    opacity: 0;
    scale: 0.96;
  }
}

@keyframes popover-out {
  to {
    opacity: 0;
    scale: 0.96;
  }
}

[data-scope='popover'][data-part='content'] {
  transform-origin: var(--transform-origin);
}

[data-scope='popover'][data-part='content'][data-state='open'] {
  animation: popover-in 150ms ease-out;
}

[data-scope='popover'][data-part='content'][data-state='closed'] {
  animation: popover-out 100ms ease-in;
}

Styling

Headless renders no CSS. Style the content by its data attributes, and stack the positioner above the page with --layer-index, its place among the open overlays, from 0:

[data-scope='popover'][data-part='positioner'] {
  z-index: calc(1000 + var(--layer-index, 0));
}

[data-scope='popover'][data-part='content'] {
  max-block-size: var(--available-height);
  overflow: auto;
}

[data-scope='popover'][data-part='content'][hidden] {
  display: none;
}

The positioner gets its position from the platform, which writes its transform, data-placement and these variables straight into it, without a render. Leave them out of its style, and style the content rather than the positioner:

VariableValue
--reference-widththe width of the trigger or the anchor
--reference-heightits height
--available-widththe width the content has room for at its placement
--available-heightthe height it has room for
--transform-originthe edge next to the trigger, for a scale animation
--layer-indexits place among the open overlays, from 0

Anatomy

PartElementAttributes
Popover.Rootnonerenders only its children
Popover.Triggerbuttondata-scope="popover" data-part="trigger", type="button", aria-haspopup="dialog", aria-expanded, aria-controls (while open), data-state
Popover.Anchordivdata-part="anchor"
Popover.Positionerdivdata-part="positioner", data-state, data-placement, hidden (closed), inert (closing); the position and the variables in its style
Popover.Contentdivdata-part="content", role="dialog", tabindex="-1", aria-labelledby, aria-describedby, aria-modal, data-state, data-placement, hidden, inert
Popover.Titledivdata-part="title", id
Popover.Descriptiondivdata-part="description", id
Popover.CloseTriggerbuttondata-part="close-trigger", type="button"

Every part carries data-scope="popover". The content points to the title and the description by id while they are mounted; give one an id of your own, and the content follows it.

Props

Popover.Root:

PropTypeDefault
openboolean—
defaultOpenbooleanfalse
onOpenChange(details: { open: boolean }) => void—
modalbooleanfalse
portalledboolean: the positioner is in a Portaltrue
positioningthe options of Placementsee Placement
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: the content has closed—

Every other part takes asChild and the props of its element, such as className, style and ref.

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; from the trigger into it, from its end to what follows the trigger
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. Popover.Description describes it.
  • The trigger tells that it opens a dialog (aria-haspopup="dialog"), whether it is open (aria-expanded), and, while it is, which element it controls (aria-controls).
  • One layer at a time. Escape and a press outside close the top-most popover only: a popover opened from another one closes first, and a press inside it keeps both open. When a popover closes, those opened from inside it close too.
  • Modal popovers set aria-modal="true" and make the rest of the page inert, so screen readers stay inside them.

How it works

Popover.Root runs the popover machine of @contour-ui/core, popover.machine, with useMachine. Its two states are closed and open; the parts and the platform send it events:

EventSent byWhat happens
OPEN, CLOSE, TOGGLEsetOpen(), toggle(), the trigger, the close triggeropens or closes, or only reports it while open is controlled
ESCAPEEscape, on the top-most popovera close request, with closeOnEscape
INTERACT_OUTSIDEa press or focus outsidea close request, with closeOnInteractOutside
CONTROLLED.OPEN, CONTROLLED.CLOSEa new open from the ownerfollows it, without a report

While it is open, the machine runs effects that the DOM platform of Headless implements on its shared overlay modules: position (places the positioner, writes the placement back), dismiss (Escape, presses and focus outside, on a stack of layers), keepTabOrder, and, for a modal popover, trapFocus, preventScroll and hideOutside. Its actions setInitialFocus and restoreFocus move focus in as it opens and back as it closes.

Hooks

usePopover runs the machine without the parts: it takes the props of Popover.Root but lazyMount, unmountOnExit and onExitComplete, and returns the API (open, modal, portalled, placement, setOpen(), toggle()) and the props of each element: getTriggerProps(), getAnchorProps(), getPositionerProps(), getContentProps(), getTitleProps(), getDescriptionProps() and getCloseTriggerProps(). Showing and hiding the content is yours: with hidden, or with usePresence for an exit animation, as Popover.Root does.

import { Portal, usePopover } from '@contour-ui/headless-react';

function Share() {
  const popover = usePopover();
  return (
    <>
      <button {...popover.getTriggerProps()}>Share</button>
      <Portal>
        <div {...popover.getPositionerProps()} hidden={!popover.open}>
          <div {...popover.getContentProps()} aria-label="Share">
            …
          </div>
        </div>
      </Portal>
    </>
  );
}

Inside Popover.Root, usePopoverContext() returns the same API, plus presence, the presence the positioner and the content show and hide with.