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

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.

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

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

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

css
: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 + #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.
  • 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
typescript
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
}
open
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
string | HTMLElement

Where to portal the content. Unset, an embedding host's target is used, else `body`.

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.

trigger
PopoverSlotProps

Trigger element. Rendered via reka PopoverTrigger as-child.

default
PopoverSlotProps

Popover content, rendered inside the shared panel shell unless `bare`.

open
[]

Fired when the component opens.

update:open
[value: boolean]

Fired when the open state changes.

close
[]

Fired when the component closes.