Skip to content

Popover

A small dialog anchored to the element that opened it.

View source View as Markdown

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 and too little for a modal.

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

App.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, Popover } from '@maas/mirror/vue'

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

const options = {
  portal: { 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-[popup-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

  • Give the popup a name, either through a Popover.Title or an aria-label on Popover.Content. Screen readers announce it as a dialog and read that name when it opens.
  • A popover is not modal by default: the page stays live behind it and the popup closes when you press outside it. Set options.modal for the cases where the rest of the page has to wait.
  • Popover.Content teleports to the end of <body> unless you turn that off with options.portal.disabled. Leave it on where the trigger sits inside anything with overflow: hidden.
  • The content resolves everything but side and align once, during setup, so change the rest before the popup first renders or key the root to remount it.
  • For a label on an icon button, reach for Tooltip instead; a popover takes focus, and a tooltip must not.

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.

NameRequiredDescription
Popover.Root true Renders its children. It is a provider.
Popover.Trigger true Renders a <button>. Opens the popup and owns the collapsed state.
Popover.Content true Renders the teleport, the floating box and the <div role="dialog">.
Popover.ArrowRenders a <div> pointing at the anchor.
Popover.TitleRenders an <h2> that names the dialog.
Popover.DescriptionRenders a <p> that describes it.
Popover.CloseRenders a <button> that closes the popup.
<script setup lang="ts">
import { Popover } from '@maas/mirror/vue'
</script>

<template>
  <Popover.Root>
    <Popover.Trigger>Share</Popover.Trigger>
    <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

Set options.openOnHover and the trigger opens the popover after delay and closes it after closeDelay. The gap between the trigger and the popup is crossed with the pointer, so entering either one cancels the other’s timer.

<template>
  <Popover.Root
    :options="{ openOnHover: true, delay: 300, closeDelay: 150 }"
  />
</template>

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

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. Pair it with options.backdrop to lay a surface over the page while it is open.

<template>
  <Popover.Root :options="{ modal: true, backdrop: true }">
    <Popover.Trigger>Share</Popover.Trigger>
    <Popover.Content>…</Popover.Content>
  </Popover.Root>
</template>

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

Where focus goes

initialFocus and finalFocus take an element, or a function that receives the reason the popover opened or closed and returns one. false leaves focus alone.

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

<template>
  <Popover.Content :initial-focus="() => input">
    <input ref="input" />
  </Popover.Content>
</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.

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.

<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.

<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.

.popup {
  min-width: var(--rack-floating-anchor-width);
  max-height: var(--mirror-popover-popup-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.

<template>
  <Popover.Root :options="{ portal: { to: '#overlays' } }" />
  <Popover.Root :options="{ portal: { disabled: true } }" />
</template>

options.backdrop adds a fixed <div role="presentation"> behind the popup.

<template>
  <Popover.Root :options="{ backdrop: true }" />
</template>

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.

<template>
  <Popover.Root :options="{ transition: { popup: 'app-popup' } }" />
</template>
.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 has both patterns in full.

Reaching the popover from anywhere

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

<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.

.trigger[data-popup-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.

<template>
  <Popover.Content class="popup" />
</template>
.mirror-popover-floating {
  --mirror-popover-floating-z-index: 80;
}

[data-scope='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

PropTypeDefault
id
stringgenerated
open
boolean | undefinedundefined
defaultOpen
booleanfalse
options
PopoverOptionssee below

Options

OptionTypeDefault
modal
booleanfalse
openOnHover
booleanfalse
delay
number300
closeDelay
number0
disabled
booleanfalse
forceMount
booleanfalse
backdrop
booleanfalse
dismiss.escape
booleantrue
dismiss.pointerDownOutside
booleantrue
dismiss.focusOutside
booleantrue
focus.trapped
booleanfalse
focus.restore
booleantrue
focus.initial
boolean | HTMLElement | ((reason) => …)null
focus.final
boolean | HTMLElement | ((reason) => …)null
portal.to
string | HTMLElementbody
portal.disabled
booleanfalse
portal.defer
booleanfalse
floating.side
'top' | 'right' | 'bottom' | 'left''bottom'
floating.sideOffset
number8
floating.align
'start' | 'center' | 'end''center'
floating.alignOffset
number0
floating.strategy
'absolute' | 'fixed''absolute'
floating.flip
booleantrue
floating.shift
booleantrue
floating.sticky
booleanfalse
floating.collisionPadding
number8
floating.arrowPadding
number5
transition.popup
stringundefined
transition.backdrop
stringundefined

Emits

EmitPayload
update:open
boolean
openChange
boolean, PopoverChangeReason

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

Popover.Trigger

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

Props

PropTypeDefault
disabled
booleanoptions.disabled

Slot props

PropType
open
boolean
disabled
boolean
side
PopoverSide | undefined
align
PopoverAlign | undefined

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.

LayerElementPublishes
portal
nonenothing
backdrop
div[role=presentation]data-popover, data-state
floating
divdata-side, data-align, data-anchor-hidden, data-state
popup
div[role=dialog]data-popover, data-side, data-align, data-state

Props

Every prop below but forceMount, anchor, initialFocus and finalFocus is an escape hatch over the matching options.floating key, and only side and align stay reactive after mount.

PropTypeDefault
forceMount
booleanoptions.forceMount
anchor
HTMLElement | (() => HTMLElement | null)the trigger
initialFocus
boolean | HTMLElement | ((reason) => …)options.focus.initial
finalFocus
boolean | HTMLElement | ((reason) => …)options.focus.final
side
'top' | 'right' | 'bottom' | 'left''bottom'
sideOffset
number8
align
'start' | 'center' | 'end''center'
alignOffset
number0
strategy
'absolute' | 'fixed''absolute'
flip
booleantrue
shift
booleantrue
sticky
booleanfalse
collisionPadding
number8
arrowPadding
number5

Slot props

PropType
open
boolean
side
PopoverSide
align
PopoverAlign
anchorHidden
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

PropType
side
PopoverSide
align
PopoverAlign
uncentered
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

PropTypeDefault
disabled
booleanoptions.disabled

Composable

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

KeyType
isOpen
ComputedRef<boolean>
open
(reason?: PopoverChangeReason) => void
close
(reason?: PopoverChangeReason) => void
toggle
(reason?: PopoverChangeReason) => void
side
ComputedRef<PopoverSide | undefined>
align
ComputedRef<PopoverAlign | undefined>

Data attributes

PartAttributeValue
Popover.Trigger, Popover.Content, Popover.Arrow, Popover.Title, Popover.Description, Popover.Close
data-popover
the instance ID
Popover.Content
data-scope
floating | popup | backdrop
Popover.Trigger
data-popup-open
true
Popover.Content, Popover.Arrow
data-state
open | closed
Popover.Trigger, Popover.Content, Popover.Arrow
data-side
top | right | bottom | left
Popover.Trigger, Popover.Content, Popover.Arrow
data-align
start | center | end
Popover.Content
data-anchor-hidden
true
Popover.Arrow
data-uncentered
true
Popover.Trigger, Popover.Close
data-disabled
true

CSS variables

VariableDefault
--mirror-popover-popup-max-height
var(--rack-floating-available-height, none)
--mirror-popover-floating-z-index
50
--mirror-popover-backdrop-position
fixed
--mirror-popover-backdrop-z-index
50
--mirror-popover-trigger-cursor
pointer
--mirror-popover-trigger-disabled-cursor
not-allowed
--mirror-popover-close-cursor
pointer
--mirror-popover-close-disabled-cursor
not-allowed

Errors

Code
missing_popover_context
missing_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.

KeyBehaviour
Enter / Space on the triggerToggles the popup. On a non-native trigger, Space acts on keyup.
TabMoves through the popup. While modal or focus.trapped is set, it stays inside.
EscapeCloses the popup and returns focus to the trigger. Nested popovers close from the inside out.

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