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 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.
<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.Titleor anaria-labelonPopover.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.modalfor the cases where the rest of the page has to wait. Popover.Contentteleports to the end of<body>unless you turn that off withoptions.portal.disabled. Leave it on where the trigger sits inside anything withoverflow: hidden.- The content resolves everything but
sideandalignonce, 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
Tooltipinstead; 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.
| Name | Required | Description |
|---|---|---|
| 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.Arrow | Renders a <div> pointing at the anchor. | |
| Popover.Title | Renders an <h2> that names the dialog. | |
| Popover.Description | Renders a <p> that describes it. | |
| Popover.Close | Renders 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.
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. 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
Options
Emits
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
Slot props
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.
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.
Slot props
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
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
Composable
useMirrorPopover(id) reaches a popover from anywhere in the app.
Data attributes
CSS variables
Errors
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.
Focus moves into the popup when it opens by press, and stays where it is when it opens by hover.