Frappe UIFrappe UI

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.

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

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

vue
<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 focus. Set :dismissible="false" to keep it open until you close it explicitly — useful for panels with their own confirm / cancel actions.

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

vue
<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. This replaces the deprecated #body slot.

vue
<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-2xl 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.

vue
<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-lg 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:

HookElement
[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 phase are reflected as data-state="open" \| "closed" and data-motion="animated" \| "instant" for state-driven styling.

css
:where([data-slot='content-body']) {
  /* your overrides */
}

Motion

The panel animates by default — a scale-from-trigger entrance (180ms in / 140ms out). Opening via the keyboard skips the animation (data-motion="instant"), and prefers-reduced-motion is respected. No configuration is required.

Notes

  • Use #trigger + #default for the standard click popover. Both slots get { open, close }.
  • Reach for v-model:open only 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 HoverCard component instead of a popover.

Migrating from v0

The v0 API still works through v1.x — old props are mapped silently, and binding both the old and new prop logs a one-time dev warning. See the full mapping table in Migration from v0 → Popover / HoverCard.

API Reference

Show types
typescript
export type PopoverSide = 'top' | 'right' | 'bottom' | 'left'
export type PopoverAlign = 'start' | 'center' | 'end'

/** Legacy placement union (deprecated — use `side` + `align`). */
export type PopoverPlacement =
  | 'top-start'
  | 'top-end'
  | 'bottom-start'
  | 'bottom-end'
  | 'right-start'
  | 'right-end'
  | 'left-start'
  | 'left-end'
  | 'top'
  | 'bottom'
  | 'right'
  | 'left'

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

  // ---------------------------------------------------------------------------
  // Deprecated props (kept working through v1.x — see Popover.vue back-compat).
  // ---------------------------------------------------------------------------

  /** @deprecated Use `open` / `v-model:open`. */
  show?: boolean

  /** @deprecated Use `<HoverCard>` for hover behavior. */
  trigger?: 'click' | 'hover'

  /** @deprecated Use `<HoverCard>` (`hoverDelay`, in seconds). */
  hoverDelay?: number

  /** @deprecated Use `<HoverCard>` (`leaveDelay`, in seconds). */
  leaveDelay?: number

  /** @deprecated Use `side` + `align`. */
  placement?: PopoverPlacement

  /** @deprecated Use the `data-slot` CSS hooks (no-op). */
  popoverClass?: string | object | Array<string | object>

  /** @deprecated Motion is now built in (no-op). */
  transition?: 'default' | null

  /** @deprecated Use `dismissible`. */
  hideOnBlur?: boolean

  /** @deprecated Use `matchTriggerWidth`. */
  matchTargetWidth?: boolean
}

export interface PopoverEmits {
  /** Fired when the open state changes (canonical). */
  (event: 'update:open', value: boolean): void
  /** Fired when the popover opens. */
  (event: 'open'): void
  /** Fired when the popover closes. */
  (event: 'close'): void
  /** @deprecated Use `update:open`. */
  (event: 'update:show', value: boolean): void
}

/** Slot props passed to the new `#trigger` / `#default` slots. */
export interface PopoverSlotProps {
  open: () => void
  close: () => void
  toggle: (flag?: boolean | Event) => void
  isOpen: boolean
}

/**
 * Slot props passed to the deprecated `#target` / `#body` / `#body-main`
 * slots. Preserved verbatim so existing callers do not double-toggle.
 */
export interface PopoverLegacySlotProps {
  togglePopover: () => void
  updatePosition: () => void
  open: () => void
  close: () => void
  toggle: (flag?: boolean | Event) => void
  isOpen: boolean
}
open
= undefined
boolean

Controls visibility (v-model:open).

side
= "bottom"
PopoverSide

Side of the trigger to render the content on.

align
= "start"
PopoverAlign

Alignment of the content along the chosen side.

offset
= 4
number

Distance in px between the trigger and the content.

portalTo
= "body"
string | HTMLElement

Where to portal the content.

collisionPadding
= 10
number

Padding in px kept from the viewport edge during collision handling.

dismissible
= true
boolean

Whether the popover closes on outside interaction (click/focus).

matchTriggerWidth
= false
boolean

Whether the content's min-width matches the trigger width.

bare
= false
boolean

Render `#default` without the panel shell (no background, border, shadow, or rounding). The content brings its own surface. Mirrors Dialog's `bare`.

arrow
= false
boolean

Render a small arrow pointing at the trigger. Styled to match the panel surface.

show
= undefined

Deprecated — Use `open` / `v-model:open`.

trigger
= "click"

Deprecated — Use `<HoverCard>` for hover behavior.

hoverDelay
= 0

Deprecated — Use `<HoverCard>` (`hoverDelay`, in seconds).

leaveDelay
= 0.5

Deprecated — Use `<HoverCard>` (`leaveDelay`, in seconds).

placement
= undefined

Deprecated — Use `side` + `align`.

popoverClass
= undefined

Deprecated — Use the `data-slot` CSS hooks (no-op).

transition
= undefined

Deprecated — Motion is now built in (no-op).

hideOnBlur
= undefined

Deprecated — Use `dismissible`.

matchTargetWidth
= undefined

Deprecated — Use `matchTriggerWidth`.

trigger
PopoverSlotProps

Trigger element. Rendered via reka PopoverTrigger as-child.

default
PopoverSlotProps

Popover content, rendered inside the shared panel shell.

target

Deprecated — Use `#trigger`. Rendered via reka PopoverAnchor (manual wiring).

body

Deprecated — Use `#default`. Full body override (no default chrome).

body-main

Deprecated — Use `#default`. Inner content inside the default container.

open
[]

Fired when the component opens.

close
[]

Fired when the component closes.

update:open
[value: boolean]

Fired when the open state changes.

update:show
[value: boolean]

Fired when the show changes.