# Popover

A small dialog anchored to the element that opened it.

Popover is a small dialog anchored to the element that opened it. Use it for a
share sheet, a filter panel or a short form that would be too much for a
[`Tooltip`](/components/tooltip) and too little for a modal.

The example switches the teleport off, so the popup renders in place and scrolls
with the frame around it rather than with the page.

::component-preview{name="PopoverPreview"}
```vue
<template>
  <Popover.Root :options="options">
    <Popover.Trigger :class="trigger">
      Share
      <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
        <path
          d="M8 10.5V2m0 0L5 5m3-3 3 3M3 9.5v3.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9.5"
          fill="none"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Popover.Trigger>

    <Popover.Content :class="popup">
      <Popover.Arrow :class="arrow" />

      <div class="flex flex-col gap-1 p-3.5">
        <Popover.Title class="type-component-lg text-primary-on-subtle">
          Share this page
        </Popover.Title>

        <Popover.Description class="type-component-2xs text-primary-muted">
          Anyone with the link can read it.
        </Popover.Description>

        <div :class="field">
          <Input v-model="link" :class="control" aria-label="Link" readonly />
        </div>

        <Popover.Close :class="close">Done</Popover.Close>
      </div>
    </Popover.Content>
  </Popover.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Input } from '@maas/mirror/vue/Input'
import { Popover } from '@maas/mirror/vue/Popover'

const link = ref('https://mirror.maas.engineering/share/8f21')

const options = {
  teleport: { disabled: true },
  floating: { sideOffset: 10 },
}

const trigger = [
  'inline-flex h-12 items-center justify-center gap-1.5 px-[1.125rem] whitespace-nowrap',
  'rounded-component-lg border-2 border-[transparent] type-component-lg',
  'bg-primary-solid text-primary-on-solid',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'hover:bg-primary-solid-hover active:bg-primary-solid-active',
  'data-[content-open=true]:bg-primary-solid-active',
].join(' ')

const popup = [
  'relative w-80 max-w-[calc(100vw-2rem)]',
  'rounded-component-2xl bg-primary-inverted shadow-component-high',
  'p-1.5 backdrop-blur-[8rem]',
  'outline-4 outline-transparent focus-visible:focus-ring',
  '[&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
].join(' ')

const arrow = [
  'bg-primary-inverted size-3 rotate-45',
  'data-[side=top]:top-full data-[side=top]:-mt-1.5',
  'data-[side=bottom]:bottom-full data-[side=bottom]:-mb-1.5',
  'data-[side=left]:left-full data-[side=left]:-ml-1.5',
  'data-[side=right]:right-full data-[side=right]:-mr-1.5',
].join(' ')

const field = [
  'mt-2.5 flex h-12 w-full items-center',
  'rounded-component-lg border-primary-subtle border-2 px-[0.875rem]',
  'outline-4 outline-transparent focus-within:focus-ring',
].join(' ')

const control = [
  'type-component-sm leading-[normal]! text-primary-on-subtle',
  'block h-full w-full appearance-none bg-transparent outline-none',
].join(' ')

const close = [
  'mt-1.5 inline-flex h-10 items-center justify-center px-3.5',
  'rounded-component-lg type-component-sm',
  'bg-primary-subtle text-primary-on-subtle',
  'hover:bg-primary-subtle-hover active:bg-primary-subtle-active',
  'outline-4 outline-transparent focus-visible:focus-ring',
].join(' ')
</script>
```
::

## Usage guidelines

::usage-guidelines

## Anatomy

Assemble the parts. `Popover.Content` is one part over three boxes: the
teleport, the positioned element and the popup. Everything you put inside it
renders in the popup.

::component-anatomy
---
parts:
  - name: Popover.Root
    required: true
    description: 'Renders its children. It is a provider.'
    children:
      - name: Popover.Trigger
        required: true
        description: 'Renders a <button>. Opens the popup and owns the collapsed state.'
      - name: Popover.Backdrop
        description: 'Optional. Renders a <div> under the popup, in the same teleport target.'
      - name: Popover.Content
        required: true
        description: 'Renders the teleport, the floating box and the <div role="dialog">.'
        children:
          - name: Popover.Arrow
            description: 'Renders a <div> pointing at the anchor.'
          - name: Popover.Title
            description: 'Renders an <h2> that names the dialog.'
          - name: Popover.Description
            description: 'Renders a <p> that describes it.'
          - name: Popover.Close
            description: 'Renders a <button> that closes the popup.'
---
::

```vue
<script setup lang="ts">
import { Popover } from '@maas/mirror/vue/Popover'
</script>

<template>
  <Popover.Root>
    <Popover.Trigger>Share</Popover.Trigger>
    <Popover.Backdrop />
    <Popover.Content>
      <Popover.Arrow />
      <Popover.Title>Share this page</Popover.Title>
      <Popover.Description>Anyone with the link can read it.</Popover.Description>
      <Popover.Close>Done</Popover.Close>
    </Popover.Content>
  </Popover.Root>
</template>
```

## Examples

### Opening on hover

`options.trigger` lists the interactions that open the popover and defaults to
`['click']`. A list with `mouseenter` opens it after `delay.mouseenter` and
closes it after `delay.mouseleave`. The gap between the trigger and the popup
is crossed with the pointer, so entering either one cancels the other’s timer.

```vue
<template>
  <Popover.Root
    :options="{
      trigger: ['click', 'mouseenter'],
      delay: { mouseenter: 300, mouseleave: 150 },
    }"
  />
</template>
```

A supplied list replaces the default, so `['mouseenter']` also turns the
pointer press off. It does not silence the keyboard: Enter and Space keep
opening the popover either way.

A popover opened by hover leaves focus where the pointer left it. One opened by
a click moves focus into the popup.

### Modal

`options.modal` traps focus in the popup and marks the rest of the document
`inert`, so nothing behind it can be reached until it closes. Mount a
`Popover.Backdrop` to lay a surface over the page while it is open.

```vue
<template>
  <Popover.Root :options="{ modal: true, scrollLock: { padding: true } }">
    <Popover.Trigger>Share</Popover.Trigger>
    <Popover.Backdrop />
    <Popover.Content>…</Popover.Content>
  </Popover.Root>
</template>
```

The backdrop has no dismiss behavior of its own: the popup’s layer already
closes on a pointer down outside.

The page keeps scrolling behind a modal popover until `options.scrollLock` is
set. It applies while the popover is modal, and the `padding` flag pads the
page by the width of the scrollbar, so nothing shifts when the bar disappears.

### Where focus goes

The `focus` option bundles what happens to focus. `focus.initial` and
`focus.final` take an element, or a function that receives the reason the
popover opened or closed and returns one. `false` leaves focus alone.

```vue
<script setup lang="ts">
const input = ref<HTMLInputElement | null>(null)
</script>

<template>
  <Popover.Root :options="{ focus: { initial: () => input } }">
    <Popover.Content>
      <input ref="input" />
    </Popover.Content>
  </Popover.Root>
</template>
```

Without either, focus moves to the first tabbable element in the popup, or to
the popup itself, and returns to the trigger on close. Set `focus.restore` to
`false` and closing leaves focus where it is instead. `focus.trap` holds focus
inside the popup while it is open, which a modal popover already does on its
own.

`initialFocus`, `finalFocus` and `restoreFocus` on the content override the
matching `focus` keys per part.

### Positioning

`Popover.Content` positions itself against the trigger, steered by the
`floating` keys on the root’s `options`; the options table below lists every
one of them. The resolved placement comes back
as `data-side` and `data-align`; read those rather than the props you passed,
since flipping can change either one.

```vue
<template>
  <Popover.Root
    :options="{
      floating: { side: 'top', align: 'start', sideOffset: 12, sticky: true },
    }"
  />
</template>
```

Pass `anchor` to position against something else, and pass any of the ten
floating keys straight to the content to override the options for one instance.

```vue
<template>
  <Popover.Content :anchor="() => selection" side="top" :side-offset="12" />
</template>
```

The content writes `--rack-floating-anchor-width`,
`--rack-floating-anchor-height`, `--rack-floating-available-width`,
`--rack-floating-available-height` and `--rack-floating-transform-origin` onto
the floating box, which is how a popup matches the width of its trigger or
stops short of the edge of the screen.

```css
.popup {
  min-width: var(--rack-floating-anchor-width);
  max-height: var(--mirror-popover-content-max-height);
}
```

### Where the popup renders

The content teleports to the end of `<body>`, which is what keeps a popup out
of an ancestor’s `overflow: hidden` and above the rest of the page. Turn it off
to render in place, or name a target of your own.

```vue
<template>
  <Popover.Root :options="{ teleport: { target: '#overlays' } }" />
  <Popover.Root :options="{ teleport: { disabled: true } }" />
</template>
```

`Popover.Backdrop` renders through the same teleport, so the backdrop always
lands in the container the popup does.

### Animating it

The popup and the backdrop each take a Vue transition name, and the timing
lives in your CSS under that name. Popover ships without one, so a popup
unmounts as it closes until you name it.

```vue
<template>
  <Popover.Root :options="{ transition: { content: 'app-popup' } }" />
</template>
```

```css
.app-popup-enter-active,
.app-popup-leave-active {
  transition:
    opacity 150ms,
    transform 150ms;
}

.app-popup-enter-from,
.app-popup-leave-to {
  opacity: 0;
  transform: scale(0.96);
}
```

The popup stays mounted until the leave has finished. For a keyframe animation
rather than a transition, read `data-state` instead, which is `open` or
`closed` for as long as the popup is rendered, and set `options.forceMount` to
keep the popup in the DOM whatever it is doing.
[Animation](/components/animation) has both patterns in full.

Both transitions broadcast their hooks through the [emitter](#emitter), so
something that renders none of the popover’s parts can still wait for the
popup to have finished entering.

### Reaching the popover from anywhere

Give the root an `id` and `useMirrorPopover(id)` opens, closes and reads that
popover from anywhere in the app.

```vue
<script setup lang="ts">
const { isOpen, open } = useMirrorPopover(PopoverId.Share)
</script>

<template>
  <Button :aria-expanded="isOpen" @click="open()">Share</Button>
</template>
```

### Styling from state

Every part writes its state to `data-*`, so style it with attribute selectors.

```css
.trigger[data-content-open='true'] {
  background: var(--app-color-primary-bg-solid-active);
}

.popup[data-side='top'] {
  transform-origin: bottom center;
}

.arrow[data-uncentered='true'] {
  opacity: 0;
}
```

The content’s three boxes are not parts, so they are reached by their
`data-scope` or by the class each one ships. A `class` on `Popover.Content` is
applied to the popup, the box you paint.

```vue
<template>
  <Popover.Content class="popup" />
</template>
```

```css
.mirror-popover-floating {
  --mirror-popover-floating-z-index: 80;
}

.mirror-popover-backdrop {
  background: rgb(0 0 0 / 0.4);
}
```

## API reference

**Module.** A bundled `options` object on the root, and `useMirrorPopover(id)`
as the programmatic API. Every part takes `id` to resolve a popover it is not
nested inside.

### `Popover.Root`

Renders its children. It is a provider.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: '[The instance ID](#reaching-the-popover-from-anywhere).'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: open
        description: Open state. `v-model:open`.
      - label: 'boolean | undefined'
      - label: undefined
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: PopoverOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: initial.open
        description: 'Opens the popover on mount while it is [uncontrolled](/components/composition#controlled-and-uncontrolled).'
      - label: boolean
      - label: 'false'
  - items:
      - label: modal
        description: 'Makes the popover [modal](#modal).'
      - label: boolean
      - label: 'false'
  - items:
      - label: trigger
        description: 'The interactions that [open the popover](#opening-on-hover). A supplied list replaces the default.'
      - label: "Array<'click' | 'mouseenter'>"
      - label: "['click']"
  - items:
      - label: delay.mouseenter
        description: Milliseconds the pointer has to rest before it opens.
      - label: number
      - label: '300'
  - items:
      - label: delay.mouseleave
        description: Milliseconds before it closes again once the pointer leaves.
      - label: number
      - label: '0'
  - items:
      - label: disabled
        description: Disables the trigger and refuses opening.
      - label: boolean
      - label: 'false'
  - items:
      - label: forceMount
        description: Keeps the popup and the backdrop mounted while closed.
      - label: boolean
      - label: 'false'
  - items:
      - label: focus.trap
        description: Focus is trapped in the popup. `modal` implies it.
      - label: boolean
      - label: 'false'
  - items:
      - label: focus.restore
        description: 'Closing returns [focus](#where-focus-goes). It goes to `focus.final`, or back to the trigger.'
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.initial
        description: 'Where [focus goes](#where-focus-goes) on open. `null` picks the first tabbable element, then the popup.'
      - label: 'boolean | HTMLElement | ((reason) => …)'
      - label: 'null'
  - items:
      - label: focus.final
        description: 'Where focus goes on close. `null` hands it back to the trigger.'
      - label: 'boolean | HTMLElement | ((reason) => …)'
      - label: 'null'
  - items:
      - label: scrollLock
        description: 'Locks the page scroll [while the popover is modal](#modal). `padding` pads the page by the scrollbar width.'
      - label: 'boolean | { padding: boolean }'
      - label: 'false'
  - items:
      - label: keyListener.close
        description: 'The keys that close the popup, matched against `event.key`. `false` or an empty list disables closing by key.'
      - label: 'Array<string> | false'
      - label: "['Escape']"
  - items:
      - label: dismiss
        description: 'The interactions outside the popup that close it: `''pointerdown''` for a pointer down outside, `''focus''` for focus moving outside. A supplied array replaces the default, so `[]` switches outside dismissal off.'
      - label: 'Array<''pointerdown'' | ''focus''>'
      - label: "['pointerdown', 'focus']"
  - items:
      - label: teleport.target
        description: The teleport target for the content.
      - label: 'string | HTMLElement'
      - label: body
        plaintext: true
  - items:
      - label: teleport.disabled
        description: Renders the content in place instead.
      - label: boolean
      - label: 'false'
  - items:
      - label: teleport.defer
        description: 'Resolves the target after mount, for a target rendered by the same app.'
      - label: boolean
      - label: 'false'
  - items:
      - label: floating.side
        description: Preferred side. Reactive.
      - label: '''top'' | ''right'' | ''bottom'' | ''left'''
      - label: '''bottom'''
  - items:
      - label: floating.sideOffset
        description: Gap from the anchor, in pixels.
      - label: number
      - label: '8'
  - items:
      - label: floating.align
        description: Alignment along that side. Reactive.
      - label: '''start'' | ''center'' | ''end'''
      - label: '''center'''
  - items:
      - label: floating.alignOffset
        description: Shift along the alignment axis, in pixels.
      - label: number
      - label: '0'
  - items:
      - label: floating.strategy
        description: Positioning strategy.
      - label: '''absolute'' | ''fixed'''
      - label: '''absolute'''
  - items:
      - label: floating.flip
        description: Flip to the opposite side when there is no room.
      - label: boolean
      - label: 'true'
  - items:
      - label: floating.shift
        description: Slide along the side to stay in view.
      - label: boolean
      - label: 'true'
  - items:
      - label: floating.sticky
        description: Keep the popup in view once the anchor has scrolled past.
      - label: boolean
      - label: 'false'
  - items:
      - label: floating.collisionPadding
        description: Padding used by flipping and shifting.
      - label: number
      - label: '8'
  - items:
      - label: floating.arrowPadding
        description: How close the arrow may come to the corners of the popup.
      - label: number
      - label: '5'
  - items:
      - label: transition.content
        description: Vue transition name for the popup.
      - label: string
      - label: undefined
  - items:
      - label: transition.backdrop
        description: Vue transition name for the backdrop.
      - label: string
      - label: undefined
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:open
        description: The popup opens or closes.
      - label: boolean
---
::

The reason a popover opened or closed travels over the
[emitter](#emitter), not over a component emit.

### `Popover.Trigger`

Renders a `<button>` with `aria-haspopup="dialog"`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: disabled
        description: Escape hatch over the options.
      - label: boolean
      - label: options.disabled
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: contentOpen
        description: Mirrors `data-content-open`.
      - label: boolean
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: boolean
  - items:
      - label: side
        description: Mirrors `data-side`.
      - label: 'PopoverSide | undefined'
  - items:
      - label: align
        description: Mirrors `data-align`.
      - label: 'PopoverAlign | undefined'
---
::

### `Popover.Backdrop`

Renders a `<div>` behind the popup, fixed over the whole viewport. It goes
through the same teleport as the content, so backdrop and popup land in the
same container, and it enters and leaves under `options.transition.backdrop`.
Mount it yourself; nothing renders it for you.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: forceMount
        description: Escape hatch over `options.forceMount`.
      - label: 'boolean | undefined'
      - label: options.forceMount
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: Mirrors `data-state`.
      - label: boolean
---
::

### `Popover.Content`

Renders everything that floats: the teleport, the floating box and the popup.
Everything in its default slot renders inside the popup. It anchors on
`Popover.Trigger`.

#### Layers

None of the three is a part, so each is styled through its `data-scope` or the
class it ships. A `class` on the content itself is applied to the popup.

::docs-table
---
columns:
  - label: Layer
  - label: Element
  - label: Publishes
rows:
  - items:
      - label: teleport
        description: The teleport. Configured through `options.teleport`.
      - label: none
        plaintext: true
      - label: nothing
        plaintext: true
  - items:
      - label: floating
        description: '`.mirror-popover-floating`. Carries the floating styles and the `--rack-floating-*` set.'
      - label: div
      - label: '`data-side`, `data-align`, `data-anchor-hidden`, `data-state`'
        plaintext: true
  - items:
      - label: popup
        description: '`.mirror-popover-content`. Presence, dismissal, focus and the modal behavior live here.'
      - label: 'div[role=dialog]'
      - label: '`data-popover`, `data-side`, `data-align`, `data-state`'
        plaintext: true
---
::

#### Props

Every prop below but `anchor` is an escape hatch over an option: `forceMount`
over `options.forceMount`, the three focus props over the matching
`options.focus` key, and the rest over the matching `options.floating` key.
Only `side` and `align` stay reactive after mount.

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: forceMount
        description: Keeps the popup and the backdrop mounted while closed.
      - label: boolean
      - label: options.forceMount
  - items:
      - label: anchor
        description: 'Position against this element [instead of the trigger](#positioning).'
      - label: 'HTMLElement | (() => HTMLElement | null)'
      - label: the trigger
        plaintext: true
  - items:
      - label: initialFocus
        description: 'Escape hatch over [`options.focus.initial`](#where-focus-goes).'
      - label: 'boolean | HTMLElement | ((reason) => …)'
      - label: options.focus.initial
  - items:
      - label: finalFocus
        description: 'Escape hatch over [`options.focus.final`](#where-focus-goes).'
      - label: 'boolean | HTMLElement | ((reason) => …)'
      - label: options.focus.final
  - items:
      - label: restoreFocus
        description: Escape hatch over `options.focus.restore`.
      - label: 'boolean | undefined'
      - label: options.focus.restore
  - items:
      - label: side
        description: Preferred side.
      - label: '''top'' | ''right'' | ''bottom'' | ''left'''
      - label: '''bottom'''
  - items:
      - label: sideOffset
        description: Gap from the anchor, in pixels.
      - label: number
      - label: '8'
  - items:
      - label: align
        description: Alignment along that side.
      - label: '''start'' | ''center'' | ''end'''
      - label: '''center'''
  - items:
      - label: alignOffset
        description: Shift along the alignment axis, in pixels.
      - label: number
      - label: '0'
  - items:
      - label: strategy
        description: Positioning strategy.
      - label: '''absolute'' | ''fixed'''
      - label: '''absolute'''
  - items:
      - label: flip
        description: Flip to the opposite side when there is no room.
      - label: boolean
      - label: 'true'
  - items:
      - label: shift
        description: Slide along the side to stay in view.
      - label: boolean
      - label: 'true'
  - items:
      - label: sticky
        description: Keep the popup in view once the anchor has scrolled past.
      - label: boolean
      - label: 'false'
  - items:
      - label: collisionPadding
        description: Padding used by flipping and shifting.
      - label: number
      - label: '8'
  - items:
      - label: arrowPadding
        description: How close the arrow may come to the corners of the popup.
      - label: number
      - label: '5'
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: Mirrors `data-state`.
      - label: boolean
  - items:
      - label: side
        description: Resolved after collision handling.
      - label: PopoverSide
  - items:
      - label: align
        description: Resolved after collision handling.
      - label: PopoverAlign
  - items:
      - label: anchorHidden
        description: Mirrors `data-anchor-hidden`.
      - label: boolean
---
::

### `Popover.Arrow`

Renders a `<div aria-hidden="true">` pointing at the anchor. It ships
`position: absolute` and takes its offset from the same positioning pass as the
popup; the side it points away from is yours to style. It must be nested inside
a `Popover.Content`.

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: side
        description: Mirrors `data-side`.
      - label: PopoverSide
  - items:
      - label: align
        description: Mirrors `data-align`.
      - label: PopoverAlign
  - items:
      - label: uncentered
        description: Mirrors `data-uncentered`.
      - label: boolean
---
::

### `Popover.Title`

Renders an `<h2>` and names the dialog through `aria-labelledby`.

### `Popover.Description`

Renders a `<p>` and describes it through `aria-describedby`.

### `Popover.Close`

Renders a `<button>` that closes the popup.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: disabled
        description: Escape hatch over the options.
      - label: boolean
      - label: options.disabled
---
::

### Composable

`useMirrorPopover(id)` reaches a popover from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: isOpen
        description: '`true` while the popup is open.'
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: open
        description: Opens the popup.
      - label: '(reason?: PopoverChangeReason) => void'
        escape: true
  - items:
      - label: close
        description: Closes the popup.
      - label: '(reason?: PopoverChangeReason) => void'
        escape: true
  - items:
      - label: toggle
        description: Opens or closes.
      - label: '(reason?: PopoverChangeReason) => void'
        escape: true
  - items:
      - label: side
        description: The side the popup ended up on.
      - label: 'ComputedRef<PopoverSide | undefined>'
        escape: true
  - items:
      - label: align
        description: The alignment it ended up with.
      - label: 'ComputedRef<PopoverAlign | undefined>'
        escape: true
---
::

### Emitter

Every open change is broadcast app-wide through `useMirrorEmitter`, so a
listener needs no shared ancestor with the popover. The `popover:openChange`
payload carries the instance `id`, the new `open` state and the `reason` it
changed for.

```vue
<script setup lang="ts">
const emitter = useMirrorEmitter()

emitter.on('popover:openChange', ({ id, open, reason }) => {
  if (id === PopoverId.Share && !open) {
    trackDismissal(reason)
  }
})
</script>
```

`PopoverChangeReason` is one of `trigger-press`, `trigger-mouseenter`,
`trigger-mouseleave`, `outside-press`, `escape-key`, `close-press`, `focus-out`
or `imperative`.

The six transition hooks are broadcast as well, as `popover:beforeEnter`,
`popover:enter`, `popover:afterEnter`, `popover:beforeLeave`, `popover:leave`
and `popover:afterLeave`. Each payload carries the instance `id` and the `part`
that is transitioning, which is `content` or `backdrop`.

```vue
<script setup lang="ts">
emitter.on('popover:afterEnter', ({ id, part }) => {
  if (id === PopoverId.Share && part === 'content') {
    loadCollaborators()
  }
})
</script>
```

The hooks belong to the Vue transition, so they fire only while
`options.transition.content` or `options.transition.backdrop` names one. Both
are unset by default, and until you name one the popover broadcasts no
lifecycle events at all. A popover that is already open when it first
renders fires no enter hooks, and a part kept in the DOM with
`options.forceMount`, or with its own `forceMount` prop, never enters or
leaves, so it fires none of them either.

::docs-table
---
columns:
  - label: Event
  - label: Payload
rows:
  - items:
      - label: popover:openChange
        description: The popover opened or closed.
      - label: "{ id: string, open: boolean, reason: PopoverChangeReason }"
        escape: true
  - items:
      - label: popover:beforeEnter
        description: A part is about to enter.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: popover:enter
        description: A part has started entering.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: popover:afterEnter
        description: A part has finished entering.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: popover:beforeLeave
        description: A part is about to leave.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: popover:leave
        description: A part has started leaving.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: popover:afterLeave
        description: A part has finished leaving and is out of the DOM.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
---
::

`PopoverLifecyclePayload` types the lifecycle payload, and the whole map is
`PopoverEvents`. [Emitter](/components/emitter) covers the channel itself.

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_popover_context
        description: A `Popover` part rendered outside `Popover.Root` and received no `id`.
  - items:
      - label: missing_popover_content
        description: A `Popover.Arrow` rendered outside a `Popover.Content`.
---
::

## Accessibility

The trigger is a `<button>` with `aria-haspopup="dialog"`, `aria-expanded` and
`aria-controls`. The popup is a `role="dialog"` named by its title and described
by its description, and it gains `aria-modal` only while `options.modal` is set.

::docs-table
---
columns:
  - label: Key
  - label: Behavior
rows:
  - items:
      - label: '`Enter` / `Space` on the trigger'
        plaintext: true
      - label: Toggles the popup. On a non-native trigger, Space acts on `keyup`.
        plaintext: true
  - items:
      - label: Tab
      - label: Moves through the popup. While `modal` or `focus.trap` is set, it stays inside.
        plaintext: true
  - items:
      - label: Escape
      - label: Closes the popup and returns focus to the trigger. Nested popovers close from the inside out. The keys are `options.keyListener.close`.
        plaintext: true
---
::

Focus moves into the popup when it opens by press, and stays where it is when it
opens by hover.
