Skip to main content
A popover is a non-modal dialog that floats around a trigger. It is used to display contextual information to the user and should be paired with a clickable trigger element. Features
  • Focus is managed and can be customized
  • Supports both modal and non-modal modes
  • Correct DOM order is maintained after tabbing out of the popover, whether portalled or not
  • Supports arrow positioning and dynamic placement via floating-ui

Installation

npm install @zag-js/popover @zag-js/react

Usage

import { useMachine, normalizeProps } from "@zag-js/react"
import * as popover from "@zag-js/popover"
import { useId } from "react"

export function Popover() {
  const service = useMachine(popover.machine, { id: useId() })
  const api = popover.connect(service, normalizeProps)

  return (
    <>
      <button {...api.getTriggerProps()}>Open popover</button>
      <div {...api.getPositionerProps()}>
        <div {...api.getContentProps()}>
          <div {...api.getArrowProps()}>
            <div {...api.getArrowTipProps()} />
          </div>
          <p {...api.getTitleProps()}>Popover title</p>
          <p {...api.getDescriptionProps()}>Popover content goes here.</p>
          <button {...api.getCloseTriggerProps()}>Close</button>
        </div>
      </div>
    </>
  )
}

Rendering in a portal

By default the popover is rendered in the same DOM hierarchy as the trigger. To render it in a portal, pass portalled: true and wrap the positioner in your framework’s portal component.
const service = useMachine(popover.machine, {
  id: useId(),
  portalled: true,
})

Managing focus

When the popover opens, focus moves to the first focusable element. Customize this with initialFocusEl.
const inputRef = useRef(null)

const service = useMachine(popover.machine, {
  id: useId(),
  initialFocusEl: () => inputRef.current,
})
To disable automatic focus on open, set autoFocus: false.
const service = useMachine(popover.machine, {
  id: useId(),
  autoFocus: false,
})
In modal mode the popover traps focus, blocks body scroll, disables pointer interactions outside, and hides background content from screen readers.
const service = useMachine(popover.machine, {
  id: useId(),
  modal: true,
})
Modal mode sets portalled: true automatically. Wrap the positioner in a portal component.

Close behavior

The popover closes on blur and when Escape is pressed. Disable either behavior individually.
// Keep open when clicking outside
const service = useMachine(popover.machine, {
  id: useId(),
  closeOnInteractOutside: false,
})

// Keep open on Escape key
const service = useMachine(popover.machine, {
  id: useId(),
  closeOnEscape: false,
})

Controlled open state

Use open and onOpenChange to control visibility externally.
const service = useMachine(popover.machine, {
  id: useId(),
  open,
  onOpenChange(details) {
    setOpen(details.open)
  },
})

Changing placement

Set positioning.placement to position the popover relative to the trigger.
const service = useMachine(popover.machine, {
  id: useId(),
  positioning: {
    placement: "top-start",
  },
})
Supported placements: "top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "left", "left-start", "left-end", "right", "right-start", "right-end".

API reference

open
boolean
The controlled open state of the popover.
defaultOpen
boolean
The initial open state when rendered (uncontrolled).
modal
boolean
Whether the popover should be modal. Traps focus and blocks interaction outside.
portalled
boolean
Whether to render the content in a portal. Defaults to true when modal is true.
autoFocus
boolean
Whether to move focus into the popover when it opens. Defaults to true.
initialFocusEl
() => HTMLElement | null
A function that returns the element to focus when the popover opens.
closeOnInteractOutside
boolean
Whether to close the popover when clicking or focusing outside. Defaults to true.
closeOnEscape
boolean
Whether to close the popover when the Escape key is pressed. Defaults to true.
positioning
PositioningOptions
Options for the floating-ui positioner. Supports placement, offset, flip, shift, and more.
onOpenChange
(details: { open: boolean }) => void
Callback fired when the popover opens or closes.

Styling

Each part has a data-part attribute. When the popover is open, data-state and data-placement are added to the trigger and content.
[data-part="trigger"][data-state="open"] { /* trigger when open */ }
[data-part="trigger"][data-state="closed"] { /* trigger when closed */ }

[data-part="content"][data-state="open"] { /* content when open */ }
[data-part="content"][data-state="closed"] { /* content when closed */ }

[data-part="trigger"][data-placement="bottom-start"] { /* placement-aware trigger */ }
[data-part="content"][data-placement="bottom-start"] { /* placement-aware content */ }

Arrow styling

The arrow requires CSS variables to render correctly.
[data-part="arrow"] {
  --arrow-background: white;
  --arrow-size: 16px;
}
For a drop shadow on the arrow, use filter: drop-shadow(...) on the content element, or use the --arrow-shadow-color variable.
[data-part="arrow"] {
  --arrow-shadow-color: rgba(0, 0, 0, 0.15);
}

Accessibility

Adheres to the Dialog WAI-ARIA design pattern.

Keyboard interactions

KeyDescription
Space / EnterOpens or closes the popover when the trigger is focused
EscapeCloses the popover and returns focus to the trigger
TabMoves focus to the next focusable element inside the popover
Shift + TabMoves focus to the previous focusable element inside the popover

Build docs developers (and LLMs) love