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
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>
</>
)
}
<script setup>
import { useMachine, normalizeProps } from "@zag-js/vue"
import * as popover from "@zag-js/popover"
import { useId, computed } from "vue"
const service = useMachine(popover.machine, { id: useId() })
const api = computed(() => popover.connect(service, normalizeProps))
</script>
<template>
<button v-bind="api.getTriggerProps()">Open popover</button>
<div v-bind="api.getPositionerProps()">
<div v-bind="api.getContentProps()">
<div v-bind="api.getArrowProps()">
<div v-bind="api.getArrowTipProps()" />
</div>
<p v-bind="api.getTitleProps()">Popover title</p>
<p v-bind="api.getDescriptionProps()">Popover content.</p>
<button v-bind="api.getCloseTriggerProps()">Close</button>
</div>
</div>
</template>
import { useMachine, normalizeProps } from "@zag-js/solid"
import * as popover from "@zag-js/popover"
import { createMemo, createUniqueId } from "solid-js"
export function Popover() {
const service = useMachine(popover.machine, { id: createUniqueId() })
const api = createMemo(() => popover.connect(service, normalizeProps))
return (
<>
<button {...api().getTriggerProps()}>Open popover</button>
<div {...api().getPositionerProps()}>
<div {...api().getContentProps()}>
<p {...api().getTitleProps()}>Title</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,
})
Modal mode
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
The controlled open state of the popover.
The initial open state when rendered (uncontrolled).
Whether the popover should be modal. Traps focus and blocks interaction outside.
Whether to render the content in a portal. Defaults to true when modal is true.
Whether to move focus into the popover when it opens. Defaults to true.
A function that returns the element to focus when the popover opens.
Whether to close the popover when clicking or focusing outside. Defaults to true.
Whether to close the popover when the Escape key is pressed. Defaults to true.
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
| Key | Description |
|---|
Space / Enter | Opens or closes the popover when the trigger is focused |
Escape | Closes the popover and returns focus to the trigger |
Tab | Moves focus to the next focusable element inside the popover |
Shift + Tab | Moves focus to the previous focusable element inside the popover |