Skip to content
Destyler UI Destyler UI Destyler UI

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

Install

Install the component from your command line.

Terminal window
      
        
npm install @destyler/popover @destyler/vue
Terminal window
      
        
npm install @destyler/popover @destyler/react
Terminal window
      
        
npm install @destyler/popover @destyler/svelte
Terminal window
      
        
npm install @destyler/popover @destyler/solid

Anatomy

Import all parts and piece them together.

<script setup lang="ts">
import * as popover from '@destyler/popover'
import { normalizeProps, useMachine } from '@destyler/vue'
import { computed, useId } from 'vue'
const [state, send] = useMachine(popover.machine({ id: useId() }))
const api = computed(() => popover.connect(state.value, send, normalizeProps))
</script>
<template>
<button v-bind="api.getTriggerProps()"></button>
<Teleport v-if="api.open" to="body">
<div v-bind="api.getPositionerProps()">
<div v-bind="api.getArrowProps()">
<div v-bind="api.getArrowTipProps()"></div>
</div>
<div v-bind="api.getContentProps()">
<div v-bind="api.getTitleProps()"></div>
<div v-bind="api.getDescriptionProps()"></div>
<button v-bind="api.getCloseTriggerProps()"/>
</div>
</div>
</Teleport>
</template>
import * as popover from '@destyler/popover'
import { normalizeProps, useMachine } from '@destyler/react'
import { useId } from 'react'
import { createPortal } from 'react-dom'
export default function Popover() {
const [state, send] = useMachine(popover.machine({
id: useId(),
}))
const api = popover.connect(state, send, normalizeProps)
return (
<>
<button {...api.getTriggerProps()}></button>
{api.open && createPortal(
<div {...api.getPositionerProps()}>
<div {...api.getArrowProps()}>
<div {...api.getArrowTipProps()} />
</div>
<div {...api.getContentProps()}>
<div {...api.getTitleProps()}></div>
<div {...api.getDescriptionProps()}></div>
<button {...api.getCloseTriggerProps()}/>
</div>
</div>,
document.body,
)}
</>
)
}
<script lang="ts">
import * as popover from '@destyler/popover'
import { normalizeProps, useMachine,portal } from '@destyler/svelte'
const id = $props.id()
const [state, send] = useMachine(popover.machine({ id }))
const api = $derived(popover.connect(state, send, normalizeProps))
</script>
<button {...api.getTriggerProps()}></button>
{#if api.open}
<div use:portal>
<div {...api.getPositionerProps()}>
<div {...api.getArrowProps()}>
<div {...api.getArrowTipProps()}></div>
</div>
<div {...api.getContentProps()}>
<div {...api.getTitleProps()}></div>
<div {...api.getDescriptionProps()}></div>
<button {...api.getCloseTriggerProps()}></button>
</div>
</div>
</div>
{/if}
import * as popover from '@destyler/popover'
import { normalizeProps, useMachine } from '@destyler/solid'
import { createMemo, createUniqueId } from 'solid-js'
import { Portal } from 'solid-js/web'
export default function Popover() {
const [state, send] = useMachine(popover.machine({
id: createUniqueId(),
}))
const api = createMemo(() => popover.connect(state, send, normalizeProps))
return (
<>
<button {...api().getTriggerProps()}></button>
{api().open && (
<Portal mount={document.body}>
<div {...api().getPositionerProps()}>
<div {...api().getArrowProps()}>
<div {...api().getArrowTipProps()} />
</div>
<div {...api().getContentProps()}>
<div {...api().getTitleProps()}></div>
<div {...api().getDescriptionProps()}></div>
<button {...api().getCloseTriggerProps()}/>
</div>
</div>
</Portal>
)}
</>
)
}

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.

<script setup lang="ts">
import * as popover from '@destyler/popover'
import { normalizeProps, useMachine } from '@destyler/vue'
import { computed, useId, ref } from 'vue'
const inputRef = ref(null)
const [state, send] = useMachine(popover.machine({
id: useId(),
initialFocusEl: () => inputRef.value,
}))
const api = computed(() => popover.connect(state.value, send, normalizeProps))
</script>
<template>
<button v-bind="api.getTriggerProps()"></button>
<Teleport v-if="api.open" to="body">
<div v-bind="api.getPositionerProps()">
<div v-bind="api.getContentProps()">
<input ref="inputRef"/>
</div>
</div>
</Teleport>
</template>
import * as popover from '@destyler/popover'
import { normalizeProps, useMachine } from '@destyler/react'
import { useId, useRef } from 'react'
import { createPortal } from 'react-dom'
export default function Popover() {
const inputRef = useRef(null)
const [state, send] = useMachine(popover.machine({
id: useId(),
initialFocusEl: () => inputRef.current,
}))
const api = popover.connect(state, send, normalizeProps)
return (
<>
<button {...api.getTriggerProps()}></button>
{api.open && createPortal(
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<input ref={inputRef} />
</div>
</div>,
document.body,
)}
</>
)
}
<script lang="ts">
import * as popover from '@destyler/popover'
import { normalizeProps, useMachine,portal } from '@destyler/svelte'
let inputRef: HTMLInputElement | null = null
const id = $props.id()
const [state, send] = useMachine(popover.machine({
id,
initialFocusEl: () => inputRef,
}))
const api = $derived(popover.connect(state, send, normalizeProps))
</script>
<button {...api.getTriggerProps()}></button>
{#if api.open}
<div use:portal>
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<input bind:this={inputRef} />
</div>
</div>
</div>
{/if}
import * as popover from '@destyler/popover'
import { normalizeProps, useMachine } from '@destyler/solid'
import { createMemo, createUniqueId, createSignal } from 'solid-js'
import { Portal } from 'solid-js/web'
export default function Popover() {
const [inputRef, setInputRef] = createSignal()
const [state, send] = useMachine(popover.machine({
id: createUniqueId(),
initialFocusEl: inputRef,
}))
const api = createMemo(() => popover.connect(state, send, normalizeProps))
return (
<>
<button {...api().getTriggerProps()}></button>
{api().open && (
<Portal mount={document.body}>
<div {...api().getPositionerProps()}>
<div {...api().getContentProps()}>
<input ref={setInputRef} />
</div>
</div>
</Portal>
)}
</>
)
}

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.

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 closeOnInteractOutside property and set it to false.

const [state, send] = 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 [state, send] = useMachine(
popover.machine({
closeOnEscape: true,
}),
)

Changing the placement

To change the placement 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 state changes

When the popover is opened or closed, the onOpenChange callback is invoked.

const [state, send] = useMachine(
popover.machine({
onOpenChange(details) {
// details => { open: boolean }
console.log("Popover", details.open)
},
}),
)

Styling Guide

Earlier, we mentioned that each collapse 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"] {
/* styles for the expanded state */
}
[data-part="content"][data-state="open"] {
/* styles for the expanded state */
}
[data-part="trigger"][data-placement="top-start"] {
/* 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=""] {
/* styles for computed placement */
}
[data-part="content"][data-placement="top-start"] {
/* 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:

ids
Partial<{ 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.
modal
boolean
Whether 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 popover
portalled
boolean
Whether the popover is portalled. This will proxy the tabbing behavior regardless of the DOM position of the popover content.
autoFocus
boolean
Whether to automatically set focus on the first focusable content within the popover when opened.
initialFocusEl
() => HTMLElement
The element to focus on when the popover is opened.
closeOnInteractOutside
boolean
Whether to close the popover when the user clicks outside of the popover.
closeOnEscape
boolean
Whether to close the popover when the escape key is pressed.
onOpenChange
(details: OpenChangeDetails) => void
Function invoked when the popover opens or closes
positioning
PositioningOptions
The user provided options used to position the popover content
open
boolean
Whether the popover is open
open.controlled
boolean
Whether the popover is controlled by the user
id
string
The unique identifier of the machine.
getRootNode
() => Node | ShadowRoot | Document
A 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) => void
Function called when the escape key is pressed
onPointerDownOutside
(event: PointerDownOutsideEvent) => void
Function called when the pointer is pressed down outside the component
onFocusOutside
(event: FocusOutsideEvent) => void
Function called when the focus is moved outside the component
onInteractOutside
(event: InteractOutsideEvent) => void
Function called when an interaction happens outside the component
persistentElements
(() => 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:

portalled
boolean
Whether the popover is portalled.
open
boolean
Whether the popover is open
setOpen
(open: boolean) => void
Function to open or close the popover
reposition
(options?: Partial<PositioningOptions>) => void
Function to reposition the popover

Data Attributes

Trigger

name
desc
data-scope
popover
data-part
trigger
data-placement
The placement of the trigger
data-state
"open" | "closed"

Indicator

name
desc
data-scope
popover
data-part
indicator
data-state
"open" | "closed"

Positioner

name
desc
data-scope
popover
data-part
positioner
data-state
"open" | "closed"

Content

name
desc
data-scope
popover
data-part
content
data-state
"open" | "closed"
data-expanded
Present when expanded
data-placement
The placement of the content

Title

name
desc
data-scope
popover
data-part
content

Description

name
desc
data-scope
popover
data-part
description

Close Trigger

name
desc
data-scope
popover
data-part
close-trigger

Accessibility

Keyboard Interaction

name
desc
Space
Opens/closes the popover.
Enter
Opens/closes the popover.
Tab
Moves 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 + Tab
Moves focus to the previous focusable element within the content Note: If there are no focusable elements, focus is moved to the trigger.
Esc
Closes the popover and moves focus to the trigger.