Popover
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 modal and non-modal modes
- Ensures correct DOM order after tabbing out of the popover, whether it's portalled or not
Installation
To use the popover machine in your project, run the following command in your command line:
npm install @zag-js/popover @zag-js/react # or yarn add @zag-js/popover @zag-js/react
npm install @zag-js/popover @zag-js/solid # or yarn add @zag-js/popover @zag-js/solid
npm install @zag-js/popover @zag-js/vue # or yarn add @zag-js/popover @zag-js/vue
npm install @zag-js/popover @zag-js/svelte # or yarn add @zag-js/popover @zag-js/svelte
Anatomy
To set up the popover correctly, you'll need to understand its anatomy and how we name its parts.
Each part includes a
data-partattribute to help identify them in the DOM.
Usage
First, import the popover package into your project
import * as popover from "@zag-js/popover"
The popover package exports two key functions:
machine— The state machine logic for the popover widget.connect— The function that translates the machine's state to JSX attributes and event handlers.
You'll need to provide a unique
idto theuseMachinehook. This is used to ensure that every part has a unique identifier.
Next, import the required hooks and functions for your framework and use the popover machine in your project 🔥
import { useId } from "react" import * as popover from "@zag-js/popover" import { useMachine, normalizeProps, Portal } from "@zag-js/react" export function Popover() { const service = useMachine(popover.machine, { id: useId() }) const api = popover.connect(service, normalizeProps) const Wrapper = api.portalled ? Portal : React.Fragment return ( <div> <button {...api.getTriggerProps()}>Click me</button> <Wrapper> <div {...api.getPositionerProps()}> <div {...api.getContentProps()}> <div {...api.getTitleProps()}>Presenters</div> <div {...api.getDescriptionProps()}>Description</div> <button>Action Button</button> <button {...api.getCloseTriggerProps()}>X</button> </div> </div> </Wrapper> </div> ) }
import * as popover from "@zag-js/popover" import { normalizeProps, useMachine } from "@zag-js/solid" import { createMemo, createUniqueId } from "solid-js" export function Popover() { const service = useMachine(popover.machine, { id: createUniqueId() }) const api = createMemo(() => popover.connect(service, normalizeProps)) return ( <div> <button {...api().getTriggerProps()}>Click me</button> <div {...api().getPositionerProps()}> <div {...api().getContentProps()}> <div {...api().getTitleProps()}>Popover Title</div> <div {...api().getDescriptionProps()}>Description</div> <button {...api().getCloseTriggerProps()}>X</button> </div> </div> </div> ) }
<script setup> import * as popover from "@zag-js/popover" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed, Teleport, Fragment } from "vue" const service = useMachine(popover.machine, { id: "1" }) const api = computed(() => popover.connect(service, normalizeProps)) </script> <template> <div ref="ref"> <button v-bind="api.getTriggerProps()">Click me</button> <Teleport to="body" :disabled="!api.portalled"> <div v-bind="api.getPositionerProps()"> <div v-bind="api.getContentProps()"> <div v-bind="api.getTitleProps()">Presenters</div> <div v-bind="api.getDescriptionProps()">Description</div> <button>Action Button</button> <button v-bind="api.getCloseTriggerProps()">X</button> </div> </div> </Teleport> </div> </template>
<script lang="ts"> import * as popover from "@zag-js/popover" import { portal, useMachine, normalizeProps } from "@zag-js/svelte" const id = $props.id() const service = useMachine(popover.machine, { id }) const api = $derived(popover.connect(service, normalizeProps)) </script> <button {...api.getTriggerProps()}>Click me</button> <div use:portal={{ disabled: !api.portalled }} {...api.getPositionerProps()}> <div {...api.getContentProps()}> <div {...api.getTitleProps()}>Presenters</div> <div {...api.getDescriptionProps()}>Description</div> <button>Action Button</button> <button {...api.getCloseTriggerProps()}>X</button> </div> </div>
Rendering the popover in a portal
By default, the popover is rendered in the same DOM hierarchy as the trigger. To
render the popover within a portal, pass portalled: true property to the
machine's context.
Note: This requires that you render the component within a
Portalbased on the framework you use.
import * as popover from "@zag-js/popover" import { useMachine, normalizeProps, Portal } from "@zag-js/react" import * as React from "react" export function Popover() { const service = useMachine(popover.machine, { id: "1" }) const api = popover.connect(service, normalizeProps) return ( <div> <button {...api.getTriggerProps()}>Click me</button> <Portal> <div {...api.getPositionerProps()}> <div {...api.getContentProps()}> <div {...api.getTitleProps()}>Presenters</div> <div {...api.getDescriptionProps()}>Description</div> <button>Action Button</button> <button {...api.getCloseTriggerProps()}>X</button> </div> </div> </Portal> </div> ) }
import * as popover from "@zag-js/popover" import { normalizeProps, useMachine } from "@zag-js/solid" import { createMemo, createUniqueId } from "solid-js" import { Portal } from "solid-js/web" export function Popover() { const service = useMachine(popover.machine, { id: createUniqueId() }) const api = createMemo(() => popover.connect(service, normalizeProps)) return ( <div> <button {...api().getTriggerProps()}>Click me</button> <Portal> <div {...api().getPositionerProps()}> <div {...api().getContentProps()}> <div {...api().getTitleProps()}>Popover Title</div> <div {...api().getDescriptionProps()}>Description</div> <button {...api().getCloseTriggerProps()}>X</button> </div> </div> </Portal> </div> ) }
<script setup> import * as popover from "@zag-js/popover" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed, Teleport } from "vue" const service = useMachine(popover.machine, { id: "1" }) const api = computed(() => popover.connect(service, normalizeProps)) </script> <template> <div ref="ref"> <button v-bind="api.getTriggerProps()">Click me</button> <Teleport to="body"> <div v-bind="api.getPositionerProps()"> <div v-bind="api.getContentProps()"> <div v-bind="api.getTitleProps()">Presenters</div> <div v-bind="api.getDescriptionProps()">Description</div> <button>Action Button</button> <button v-bind="api.getCloseTriggerProps()">X</button> </div> </div> </Teleport> </div> </template>
<script lang="ts"> import * as popover from "@zag-js/popover" import { portal, useMachine, normalizeProps } from "@zag-js/svelte" const id = $props.id() const service = useMachine(popover.machine, { id }) const api = $derived(popover.connect(service, normalizeProps)) </script> <button {...api.getTriggerProps()}>Click me</button> <div use:portal {...api.getPositionerProps()}> <div {...api.getContentProps()}> <div {...api.getTitleProps()}>Presenters</div> <div {...api.getDescriptionProps()}>Description</div> <button>Action Button</button> <button {...api.getCloseTriggerProps()}>X</button> </div> </div>
Managing focus within popover
When the popover open, focus is automatically set to the first focusable element
within the popover. To customize the element that should get focus, set the
initialFocusEl property in the machine's context.
export function Popover() { // initial focused element ref const inputRef = useRef(null) const service = useMachine(popover.machine, { id: "1", initialFocusEl: () => inputRef.current, }) // ... return ( //... <input ref={inputRef} /> // ... ) }
export function Popover() { // initial focused element signal const [inputEl, setInputEl] = createSignal() const service = useMachine(popover.machine, { initialFocusEl: inputEl, }) // ... return ( //... <input ref={setInputEl} /> // ... ) }
<script setup> import { ref } from "vue" // initial focused element ref const inputRef = ref(null) const service = useMachine(popover.machine, { initialFocusEl: () => inputRef.value, }) </script> <template> <input ref="inputRef" /> </template>
<script lang="ts"> // initial focused element ref let inputRef: HTMLInputElement | null = null const service = useMachine(popover.machine, { initialFocusEl: () => inputRef, }) // ... </script> <!-- ... --> <input bind:this={inputRef} /> <!-- ... -->
Changing the modality
In some cases, you might want the popover to be modal. This means that it'll:
- trap focus within its content
- block scrolling on the
body - disable pointer interactions outside the popover
- hide content behind the popover from screen readers
To make the popover modal, set the modal: true property in the machine's
context. When modal: true, we set the portalled attribute to true as well.
Note: This requires that you render the component within a
Portal.
const service = useMachine(popover.machine, { modal: true, })
Close behavior
The popover is designed to close on blur and when the esc key is pressed.
To prevent it from closing on blur (clicking or focusing outside), pass the
closeOnInteractOutside property and set it to false.
const service = useMachine(popover.machine, { closeOnInteractOutside: true, })
To prevent it from closing when the esc key is pressed, pass the
closeOnEscape property and set it to false.
const service = useMachine(popover.machine, { closeOnEscape: true, })
Adding an arrow
To render an arrow within the popover, use the api.getArrowProps() and
api.getArrowTipProps().
//... const api = popover.connect(service, normalizeProps) //... return ( <div {...api.getPositionerProps()}> <div {...api.getContentProps()}> <div {...api.getArrowProps()}> <div {...api.getArrowTipProps()} /> </div> //... </div> </div> ) //...
Changing the placement
To change the placement of the popover, set the positioning.placement property
in the machine's context.
const service = useMachine(popover.machine, { positioning: { placement: "top-start", }, })
Listening for open state changes
When the popover is opened or closed, the onOpenChange callback is invoked.
const service = useMachine(popover.machine, { onOpenChange(details) { // details => { open: boolean } console.log("Popover", details.open) }, })
Usage within dialog
When using the popover within a dialog, avoid rendering the popover in a
Portal or Teleport. This is because the dialog will trap focus within it,
and the popover will be rendered outside the dialog.
Styling guide
Earlier, we mentioned that each popover part has a data-part attribute added
to them to select and style them in the DOM.
Open and closed state
When the popover is expanded, we add a data-state and data-placement
attribute to the trigger.
[data-part="trigger"][data-state="open|closed"] { /* styles for the expanded state */ } [data-part="content"][data-state="open|closed"] { /* styles for the expanded state */ } [data-part="trigger"][data-placement="(top|bottom)-(start|end)"] { /* styles for computed placement */ }
Position aware
When the popover is expanded, we add a data-state and data-placement
attribute to the trigger.
[data-part="trigger"][data-placement="(top|bottom)-(start|end)"] { /* styles for computed placement */ } [data-part="content"][data-placement="(top|bottom)-(start|end)"] { /* styles for computed placement */ }
Arrow
The arrow element requires specific css variables to be set for it to show correctly.
[data-part="arrow"] { --arrow-background: white; --arrow-size: 16px; }
A common technique for adding a shadow to the arrow is to use set
filter: drop-down(...) css property on the content element. Alternatively, you
can use the --arrow-shadow-color variable.
[data-part="arrow"] { --arrow-shadow-color: gray; }
Methods and Properties
Machine Context
The popover machine exposes the following context properties:
idsPartial<{ anchor: string; trigger: string; content: string; title: string; description: string; closeTrigger: string; positioner: string; arrow: string; }>The ids of the elements in the popover. Useful for composition.modalbooleanWhether the popover should be modal. When set to `true`: - interaction with outside elements will be disabled - only popover content will be visible to screen readers - scrolling is blocked - focus is trapped within the popoverportalledbooleanWhether the popover is portalled. This will proxy the tabbing behavior regardless of the DOM position of the popover content.autoFocusbooleanWhether to automatically set focus on the first focusable content within the popover when opened.initialFocusEl() => HTMLElementThe element to focus on when the popover is opened.closeOnInteractOutsidebooleanWhether to close the popover when the user clicks outside of the popover.closeOnEscapebooleanWhether to close the popover when the escape key is pressed.onOpenChange(details: OpenChangeDetails) => voidFunction invoked when the popover opens or closespositioningPositioningOptionsThe user provided options used to position the popover contentopenbooleanThe controlled open state of the popoverdefaultOpenbooleanThe initial open state of the popover when rendered. Use when you don't need to control the open state of the popover.idstringThe unique identifier of the machine.getRootNode() => Node | ShadowRoot | DocumentA root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.dir"ltr" | "rtl"The document's text/writing direction.onEscapeKeyDown(event: KeyboardEvent) => voidFunction called when the escape key is pressedonRequestDismiss(event: LayerDismissEvent) => voidFunction called when this layer is closed due to a parent layer being closedonPointerDownOutside(event: PointerDownOutsideEvent) => voidFunction called when the pointer is pressed down outside the componentonFocusOutside(event: FocusOutsideEvent) => voidFunction called when the focus is moved outside the componentonInteractOutside(event: InteractOutsideEvent) => voidFunction called when an interaction happens outside the componentpersistentElements(() => Element)[]Returns the persistent elements that: - should not have pointer-events disabled - should not trigger the dismiss event
Machine API
The popover api exposes the following methods:
portalledbooleanWhether the popover is portalled.openbooleanWhether the popover is opensetOpen(open: boolean) => voidFunction to open or close the popoverreposition(options?: Partial<PositioningOptions>) => voidFunction to reposition the popover
Data Attributes
CSS Variables
Accessibility
Adheres to the Dialog WAI-ARIA design pattern.
Keyboard Interactions
- SpaceOpens/closes the popover.
- EnterOpens/closes the popover.
- TabMoves focus to the next focusable element within the content.
Note: If there are no focusable elements, focus is moved to the next focusable element after the trigger. - Shift + TabMoves focus to the previous focusable element within the content
Note: If there are no focusable elements, focus is moved to the trigger. - EscCloses the popover and moves focus to the trigger.