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/vue # or yarn add @zag-js/popover @zag-js/vue
npm install @zag-js/popover @zag-js/vue # or yarn add @zag-js/popover @zag-js/vue
npm install @zag-js/popover @zag-js/solid # or yarn add @zag-js/popover @zag-js/solid
This command will install the framework agnostic popover logic and the reactive utilities for your framework of choice.
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-part
attribute to help identify them in the DOM.
On a high level, the popover consists of:
- Trigger: The trigger for the popover.
- Positioner: The element that positions the popover.
- Content: The container for the popover's content.
- Title: The accessible title for the popover.
- Description: The accessible description for the popover.
- Close Button: The trigger to close the popover.
When positioning the popover, you might use these parts:
- Arrow: The arrow element that points to the trigger.
- Anchor: The optional reference element that the popover is anchored or positioned. By default, we use the trigger as the anchor element.
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
id
to theuseMachine
hook. 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 [state, send] = useMachine(popover.machine({ id: useId() })) const api = popover.connect(state, send, normalizeProps) const Wrapper = api.portalled ? Portal : React.Fragment return ( <div> <button {...api.triggerProps}>Click me</button> <Wrapper> <div {...api.positionerProps}> <div {...api.contentProps}> <div {...api.titleProps}>Presenters</div> <div {...api.descriptionProps}>Description</div> <button>Action Button</button> <button {...api.closeTriggerProps}>X</button> </div> </div> </Wrapper> </div> ) }
import * as popover from "@zag-js/popover" import { normalizeProps, useMachine } from "@zag-js/vue" import { defineComponent, computed, h, Fragment, Teleport } from "vue" export default defineComponent({ name: "Popover", setup() { const [state, send] = useMachine(popover.machine({ id: "1" })) const apiRef = computed(() => popover.connect(state.value, send, normalizeProps), ) return () => { const api = apiRef.value const Wrapper = api.portalled ? Teleport : Fragment return ( <div> <button {...api.triggerProps}>Click me</button> <Wrapper> <div {...api.positionerProps}> <div {...api.contentProps}> <div {...api.titleProps}>Presenters</div> <div {...api.descriptionProps}>Description</div> <button>Action Button</button> <button {...api.closeTriggerProps}>X</button> </div> </div> </Wrapper> </div> ) } }, })
<script setup> import * as popover from "@zag-js/popover"; import { normalizeProps, useMachine } from "@zag-js/vue"; import { computed, Teleport, Fragment } from "vue"; const [state, send] = useMachine(popover.machine({ id: "1" })); const api = computed(() => popover.connect(state.value, send, normalizeProps)); const Wrapper = api.value.portalled ? Teleport : Fragment; </script> <template> <div ref="ref"> <button v-bind="api.triggerProps">Click me</button> <Wrapper> <div v-bind="api.positionerProps"> <div v-bind="api.contentProps"> <div v-bind="api.titleProps">Presenters</div> <div v-bind="api.descriptionProps">Description</div> <button>Action Button</button> <button v-bind="api.closeTriggerProps">X</button> </div> </div> </Wrapper> </div> </template>
import * as popover from "@zag-js/popover" import { normalizeProps, useMachine } from "@zag-js/solid" import { createMemo, createUniqueId } from "solid-js" export function Popover() { const [state, send] = useMachine(popover.machine({ id: createUniqueId() })) const api = createMemo(() => popover.connect(state, send, normalizeProps)) return ( <div> <button {...api().triggerProps}>Click me</button> <div {...api().positionerProps}> <div {...api().contentProps}> <div {...api().titleProps}>Popover Title</div> <div {...api().descriptionProps}>Description</div> <button {...api().closeTriggerProps}>X</button> </div> </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
Portal
based 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 [state, send] = useMachine(popover.machine({ id: "1" })) const api = popover.connect(state, send, normalizeProps) return ( <div> <button {...api.triggerProps}>Click me</button> <Portal> <div {...api.positionerProps}> <div {...api.contentProps}> <div {...api.titleProps}>Presenters</div> <div {...api.descriptionProps}>Description</div> <button>Action Button</button> <button {...api.closeTriggerProps}>X</button> </div> </div> </Portal> </div> ) }
import * as popover from "@zag-js/popover" import { normalizeProps, useMachine } from "@zag-js/vue" import { defineComponent, computed, h, Fragment, Teleport } from "vue" export default defineComponent({ name: "Popover", setup() { const [state, send] = useMachine(popover.machine({ id: "1" })) const apiRef = computed(() => popover.connect(state.value, send, normalizeProps), ) return () => { const api = apiRef.value return ( <div> <button {...api.triggerProps}>Click me</button> <Teleport to="body"> <div {...api.positionerProps}> <div {...api.contentProps}> <div {...api.titleProps}>Presenters</div> <div {...api.descriptionProps}>Description</div> <button>Action Button</button> <button {...api.closeTriggerProps}>X</button> </div> </div> </Teleport> </div> ) } }, })
<script setup> import * as popover from "@zag-js/popover"; import { normalizeProps, useMachine } from "@zag-js/vue"; import { computed, Teleport } from "vue"; const [state, send] = useMachine(popover.machine({ id: "1" })); const api = computed(() => popover.connect(state.value, send, normalizeProps)); </script> <template> <div ref="ref"> <button v-bind="api.triggerProps">Click me</button> <Teleport to="body"> <div v-bind="api.positionerProps"> <div v-bind="api.contentProps"> <div v-bind="api.titleProps">Presenters</div> <div v-bind="api.descriptionProps">Description</div> <button>Action Button</button> <button v-bind="api.closeTriggerProps">X</button> </div> </div> </Teleport> </div> </template>
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 [state, send] = useMachine(popover.machine({ id: createUniqueId() })) const api = createMemo(() => popover.connect(state, send, normalizeProps)) return ( <div> <button {...api().triggerProps}>Click me</button> <Portal> <div {...api().positionerProps}> <div {...api().contentProps}> <div {...api().titleProps}>Popover Title</div> <div {...api().descriptionProps}>Description</div> <button {...api().closeTriggerProps}>X</button> </div> </div> </Portal> </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 [state, send] = useMachine( popover.machine({ id: "1", initialFocusEl: () => inputRef.current, }), ) // ... return ( //... <input ref={inputRef} /> // ... ) }
export default defineComponent({ name: "Popover", setup() { // initial focused element ref const inputRef = ref(null) const [state, send] = useMachine( popover.machine({ initialFocusEl: () => inputRef.value, }), ) // ... return () => ( //... <input ref={inputRef} /> // ... ) }, })
<script setup> import { ref } from "vue"; // initial focused element ref const inputRef = ref(null); const [state, send] = useMachine( popover.machine({ initialFocusEl: () => inputRef.value, }) ); </script> <template> <input ref="inputRef" /> </template>
export function Popover() { // initial focused element signal const [inputEl, setInputEl] = createSignal() const [state, send] = useMachine( popover.machine({ initialFocusEl: inputEl, }), ) // ... return ( //... <input ref={setInputEl} /> // ... ) }
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 [state, send] = 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
closeOnBlur
property and set it to false
.
const [state, send] = useMachine( popover.machine({ closeOnBlur: true, }), )
To prevent it from closing when the esc
key is pressed, pass the closeOnEsc
property and set it to false
.
const [state, send] = useMachine( popover.machine({ closeOnEsc: true, }), )
Adding an arrow
To render an arrow within the popover, use the api.arrowProps
and
api.arrowTipProps
.
//... const api = popover.connect(state, send) //... return ( <div {...api.positionerProps}> <div {...api.arrowProps}> <div {...api.arrowTipProps} /> </div> <div {...api.contentProps}>{/* ... */}</div> </div> ) //...
Changing the placement
To change the placment of the popover, set the positioning.placement
property
in the machine's context.
const [state, send] = useMachine( popover.machine({ positioning: { placement: "top-start", }, }), )
Listening for open and close events
When the popover is opened or closed, we invoke the onOpen
and onClose
callbacks.
const [state, send] = useMachine( popover.machine({ onOpen() { console.log("Popover opened") }, onClose() { console.log("Popover closed") }, }), )
Usage within dialog
When using the popover within a dialog, you'll need to 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.
Consider designing a
portalled
property in your component to allow you decide where to render the popover in a portal.
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
The popover's api
exposes the following methods:
portalled
boolean
Whether the popover is portalledisOpen
boolean
Whether the popover is openopen
() => void
Function to open the popoverclose
() => void
Function to close the popoversetPositioning
(options?: Partial<PositioningOptions>) => void
Function to reposition the popover
Edit this page on GitHub