Popover
Shows content in a floating panel anchored to a trigger, portaled out of the page flow so it never disturbs the layout. Built on reka-ui's Popover primitives, so click, keyboard, and aria wiring come for free.
Basic
#trigger is rendered through reka's PopoverTrigger as-child — clicking it (or pressing Enter / Space while focused) toggles the panel. #default renders inside the standard shell. Both slots receive open and close helpers.
<script setup lang="ts">
import { Button, Popover } from 'frappe-ui'
</script>
<template>
<Popover>
<template #trigger>
<Button>Click me</Button>
</template>
<template #default="{ close }">
<div class="p-2 text-ink-gray-9">
<p>Popover content</p>
<Button class="mt-2" @click="close()">Close</Button>
</div>
</template>
</Popover>
</template>Side and alignment
Use side (top / right / bottom / left) and align (start / center / end) to position the panel. The panel flips and shifts automatically to stay within the viewport (collisionPadding controls the gap kept from the edge).
<script setup lang="ts">
import { Button, Popover } from 'frappe-ui'
</script>
<template>
<Popover side="right" align="center">
<template #trigger>
<Button>Open on the right</Button>
</template>
<template #default>
<div class="p-3 text-p-sm text-ink-gray-7">
Positioned with <code>side="right"</code> and
<code>align="center"</code>.
</div>
</template>
</Popover>
</template>Controlled open state
Bind v-model:open to drive the panel from outside, or read it to react to open / close. The component also exposes open() and close() methods via a template ref, and emits open / close events.
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Popover } from 'frappe-ui'
const open = ref(false)
</script>
<template>
<div class="flex items-center gap-2">
<Button @click="open = !open">Toggle from outside</Button>
<Popover v-model:open="open">
<template #trigger>
<Button>Trigger</Button>
</template>
<template #default>
<div class="p-3 text-p-sm text-ink-gray-7">
Open state is controlled via <code>v-model:open</code>.
</div>
</template>
</Popover>
</div>
</template>Non-dismissible
By default the popover closes on an outside click or on Escape. Set :dismissible="false" to turn off both and keep it open until you close it explicitly — useful for panels with their own confirm / cancel actions.
<script setup lang="ts">
import { Button, Popover } from 'frappe-ui'
</script>
<template>
<Popover :dismissible="false">
<template #trigger>
<Button>Stays open</Button>
</template>
<template #default="{ close }">
<div class="p-3 text-ink-gray-9">
<p class="text-p-sm">
Clicking outside won't close this. Use the button instead.
</p>
<Button class="mt-2" @click="close()">Close</Button>
</div>
</template>
</Popover>
</template>Match trigger width
Set match-trigger-width to make the panel's min-width match the trigger. Handy for select-style menus where the panel should line up under a wide button.
<script setup lang="ts">
import { Button, Popover } from 'frappe-ui'
</script>
<template>
<Popover match-trigger-width>
<template #trigger>
<Button class="w-64">Wide trigger</Button>
</template>
<template #default>
<div class="p-3 text-p-sm text-ink-gray-7">
The content's min-width matches the trigger width.
</div>
</template>
</Popover>
</template>Bare
Set bare to drop the panel shell (background, border, shadow, rounding) so #default content can bring its own surface — useful for pickers and cards that are already styled.
<script setup lang="ts">
import { Button, Popover } from 'frappe-ui'
</script>
<template>
<Popover bare>
<template #trigger>
<Button>Custom surface</Button>
</template>
<template #default>
<!-- bare: no panel shell, the content brings its own surface -->
<div
class="max-h-72 w-64 overflow-y-auto rounded-8 bg-surface-gray-7 p-4 text-p-sm text-ink-white shadow-2xl"
>
This panel has no default chrome — the popover renders the content
directly so it can use its own background, radius, and shadow.
</div>
</template>
</Popover>
</template>Arrow
Set arrow to render a small arrow that points back at the trigger. It's styled to match the panel surface.
<script setup lang="ts">
import { Button, Popover } from 'frappe-ui'
</script>
<template>
<Popover arrow>
<template #trigger>
<Button>With arrow</Button>
</template>
<template #default>
<div class="p-3 text-p-sm text-ink-gray-7">
An arrow points back at the trigger.
</div>
</template>
</Popover>
</template>Styling
The popover ships with its panel shell baked in (rounded-6 bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5) — there are no class-injection props. Style it through the stable data-slot hooks instead:
| Hook | Element |
|---|---|
[data-slot="trigger"] | the trigger wrapper |
[data-slot="content"] | the portaled content (reka PopoverContent) |
[data-slot="content-body"] | the panel shell that owns the visuals |
Open / closed and motion rhythm are reflected as data-state="open" \| "closed" and data-motion="instant" for state-driven styling.
:where([data-slot='content-body']) {
/* your overrides */
}Motion
The panel appears instantly. An 80ms fade smooths the paint on open, and there is no exit animation — a panel that appears at a fixed spot has nothing to scale from, so an entrance would only add latency. Same for pointer and keyboard opens, and prefers-reduced-motion is respected. No configuration is required.
Notes
- Use
#trigger+#defaultfor the standard click popover. Both slots get{ open, close }. - Reach for
v-model:openonly when an external control needs to drive the panel — clicking the trigger already toggles it. - For a panel that opens on hover (profile previews, link previews), use the dedicated
HoverCardcomponent instead of a popover. - Attributes on
<Popover>are not inherited — the component renders no element of its own. Put classes and styles on the element inside#trigger.
Migrating from v0
The v0 API is gone in 1.0.0. #target becomes #trigger, #body and #body-main become #default, and placement splits into side + align. See Migration from v0 → Popover / HoverCard for the full table.
API Reference
Show types
export type PopoverSide = 'top' | 'right' | 'bottom' | 'left'
export type PopoverAlign = 'start' | 'center' | 'end'
export interface PopoverProps {
/** Controls visibility (v-model:open). */
open?: boolean
/** Side of the trigger to render the content on. */
side?: PopoverSide
/** Alignment of the content along the chosen side. */
align?: PopoverAlign
/** Distance in px between the trigger and the content. */
offset?: number
/** Where to portal the content. Unset, an embedding host's target is used, else `body`. */
portalTo?: string | HTMLElement
/** Padding in px kept from the viewport edge during collision handling. */
collisionPadding?: number
/** Whether the popover closes on outside interaction (click/focus). */
dismissible?: boolean
/** Whether the content's min-width matches the trigger width. */
matchTriggerWidth?: boolean
/**
* Render `#default` without the panel shell (no background, border, shadow,
* or rounding). The content brings its own surface. Mirrors Dialog's `bare`.
*/
bare?: boolean
/**
* Render a small arrow pointing at the trigger. Styled to match the panel
* surface.
*/
arrow?: boolean
}
export interface PopoverEmits {
/** Fired when the open state changes. */
(event: 'update:open', value: boolean): void
/** Fired when the popover opens. */
(event: 'open'): void
/** Fired when the popover closes. */
(event: 'close'): void
}
/** Slot props passed to the `#trigger` and `#default` slots. */
export interface PopoverSlotProps {
/** Whether the popover is currently open. */
open: boolean
/** Closes the popover. No-op when it is already closed. */
close: () => void
/** Flips the open state, or sets it when passed a boolean. */
toggle: (flag?: boolean | Event) => void
}
/** Methods available on a `<Popover>` template ref. */
export interface PopoverExposed {
/** Opens the popover. No-op when it is already open. */
open: () => void
/** Closes the popover. No-op when it is already closed. */
close: () => void
}Controls visibility (v-model:open).
Side of the trigger to render the content on.
Alignment of the content along the chosen side.
Distance in px between the trigger and the content.
Where to portal the content. Unset, an embedding host's target is used, else `body`.
Padding in px kept from the viewport edge during collision handling.
Whether the popover closes on outside interaction (click/focus).
Whether the content's min-width matches the trigger width.
Render `#default` without the panel shell (no background, border, shadow, or rounding). The content brings its own surface. Mirrors Dialog's `bare`.
Render a small arrow pointing at the trigger. Styled to match the panel surface.
| Slot | Payload |
|---|---|
trigger | PopoverSlotProps Trigger element. Rendered via reka PopoverTrigger as-child. |
default | PopoverSlotProps Popover content, rendered inside the shared panel shell unless `bare`. |
Trigger element. Rendered via reka PopoverTrigger as-child.
Popover content, rendered inside the shared panel shell unless `bare`.
| Event | Payload |
|---|---|
open | [] Fired when the component opens. |
update:open | [value: boolean] Fired when the open state changes. |
close | [] Fired when the component closes. |
Fired when the component opens.
Fired when the open state changes.
Fired when the component closes.