Use it for
Reach for BbPopover when a trigger should open a small panel the user then
works in: quick settings, a filter, a notifications list, an inline detail card.
Fields, switches, links and buttons all belong inside it.
Use something else when
BbTooltip: the reader only needs a sentence about the control in front of themBbDropdown: the panel is only a list of actionsBbDialog: the task owns the page until it is resolvedBbOffCanvas: it is supplementary side content, docked to an edge rather than to a control
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
A tooltip describes, a popover contains. If the reader has to move into the content and do something there, stay here.
Edit in place
Put your trigger in the activator slot and spread its props onto it.
That one spread is the whole contract: it carries the anchor, the element id,
the ARIA attributes and the open handler. Miss it and you get a button that
looks right and does nothing.
Q3 revenue review
<template>
<div class="flex items-center gap-3">
<p class="text-sm font-medium">{{ name }}</p>
<BbPopover :width="260">
<template #activator="{ props }">
<BbButton
v-bind="props"
icon="lucide:pencil"
size="sm"
variant="ghost"
>
Rename report
</BbButton>
</template>
<!-- v3 ships no close button. `close` from the default slot is what
your own Cancel and Save controls call. -->
<template #default="{ close }">
<form class="grid gap-3" @submit.prevent="save(close)">
<BbTextInput
id="popover-rename-name"
v-model="draft"
compact
label="Report name"
name="reportName"
/>
<div class="flex justify-end gap-2">
<BbButton size="sm" type="button" variant="ghost" @click="close">
Cancel
</BbButton>
<BbButton
:disabled="!draft?.trim()"
size="sm"
type="submit"
variant="primary"
>
Save
</BbButton>
</div>
</form>
</template>
</BbPopover>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbPopover, BbTextInput } from 'bitboss-ui';
const name = ref('Q3 revenue review');
const draft = ref<string | null>(name.value);
/** Commit first, dismiss second — a close that ran early loses the edit. */
function save(close: () => void) {
const next = draft.value?.trim();
if (!next) return;
name.value = next;
close();
}
</script>
width takes pixels, or a percentage of the trigger's width. Only the
percentage form re-measures when the trigger resizes; leave width off and the
panel sizes to its content.
Closing the panel
The panel renders no close button of its own. The affordance is yours, and the
default slot hands you close.
Call close after the write, not before. A handler that dismisses first and
commits second is one early return away from losing the edit silently.
Escape and a click outside dismiss the panel too, and for a read-only panel that
is enough. A panel holding a form needs a visible way out as well, Cancel beside
Save, because nothing on screen says Escape is safe. The header and footer
slots receive close on the same scope.
Coming from v2
show-close and close-label are gone.
Focus
A popover is modal. It opens as a native <dialog>, so Tab stays inside
the panel and the page behind it cannot be used while it is open.
25 of 25
<template>
<div class="flex items-center gap-3">
<BbPopover focus-target="#popover-filter-query" :width="260">
<template #activator="{ props }">
<BbButton
v-bind="props"
prepend:icon="lucide:funnel"
variant="outline"
>
Filter members
</BbButton>
</template>
<!-- The header renders first, so without `focus-target` the panel would
open with focus on Reset — one Tab away from what you came to type in. -->
<template #header="{ close }">
<div class="flex items-center justify-between gap-2">
<strong class="text-sm">Filters</strong>
<BbButton size="xs" variant="ghost" @click="reset(close)">
Reset
</BbButton>
</div>
</template>
<BbTextInput
id="popover-filter-query"
v-model="query"
compact
label="Search by name"
name="memberQuery"
/>
</BbPopover>
<p class="text-sm opacity-70">{{ matching.length }} of {{ users.length }}</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbPopover, BbTextInput } from 'bitboss-ui';
import { matches, users } from '~/demo-data';
const query = ref<string | null>(null);
const matching = computed(() =>
users.filter((user) => matches(user.fullName, query.value ?? ''))
);
/** Clearing a filter set is a commit, so it dismisses the panel too. */
function reset(close: () => void) {
query.value = null;
close();
}
</script>
Design around that: a click outside lands on the panel's own transparent backdrop, dismisses the popover, and does not reach the control underneath. Anyone wanting to press something behind the panel presses it twice.
Opening moves focus to the first focusable element; closing returns it to the
trigger. When that first element is not the one the reader came for, point
focus-target at the right one with a CSS selector.
The panel paints in the top layer, so it is never clipped by an
overflow: hidden ancestor and never needs a z-index.
Coming from v2
The restore-focus prop is gone. Restoring focus is behaviour now, not a knob.
Placement and geometry
placement puts the panel on a side of the trigger: the top, right,
bottom and left families, each with -start and -end alignment.
<template>
<div class="flex flex-wrap items-center gap-2">
<BbPopover
v-for="side in sides"
:key="side"
:placement="side"
:width="180"
>
<template #activator="{ props }">
<BbButton v-bind="props" variant="outline">{{ side }}</BbButton>
</template>
<p class="text-sm">
Anchored <strong>{{ side }}</strong
>. Near a viewport edge the panel flips to the opposite side on its
own.
</p>
</BbPopover>
</div>
</template>
<script setup lang="ts">
import { BbButton, BbPopover, type Placement } from 'bitboss-ui';
const sides: Placement[] = ['top', 'right', 'bottom-start', 'left'];
</script>
It defaults to bottom, and it flips itself near a viewport edge. Pick what
reads best in the common case rather than guarding against the exception.
Three props tune the rest:
offset: the gap from the trigger (3)padding: the gap the panel keeps from the page edge (10)boundary: the region the panel must not escape, usually a sidebar or a scrolling container
The panel stays tethered to its trigger: scroll the trigger out of view and the popover closes.
Coming from v2
A popover no longer draws an arrow, so hide-arrow and arrow-padding are
gone. If you passed :hide-arrow="false" in v2 to get a bubble with a point on
it, that shape is a BbTooltip now.
Header, footer and long content
scrollable caps the panel at the height still available and scrolls the body
inside it.
<template>
<BbPopover scrollable :width="300">
<template #activator="{ props }">
<BbButton
v-bind="props"
icon="lucide:bell"
size="sm"
variant="outline"
>
Notifications
</BbButton>
</template>
<!-- Header and footer sit outside the scroll region and keep their own
padding and divider, so neither needs `position: sticky`. -->
<template #header>
<strong class="text-sm">Order updates</strong>
</template>
<div class="grid gap-1">
<BbButton
v-for="item in items"
:key="item.reference"
class="justify-start"
size="sm"
:variant="read.has(item.reference) ? 'ghost' : 'secondary'"
@click="read.add(item.reference)"
>
{{ item.reference }} — {{ item.status }}
</BbButton>
</div>
<template #footer="{ close }">
<div class="flex justify-end">
<BbButton
prepend:icon="lucide:check"
size="sm"
variant="ghost"
@click="markAllRead(close)"
>
Mark all as read
</BbButton>
</div>
</template>
</BbPopover>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { BbButton, BbPopover } from 'bitboss-ui';
import { orders, orderStatusLabels } from '~/demo-data';
const items = orders.map((order) => ({
reference: order.reference,
status: orderStatusLabels[order.status],
}));
const read = reactive(new Set<string>());
function markAllRead(close: () => void) {
for (const item of items) read.add(item.reference);
close();
}
</script>
The header and footer slots sit outside that scroll region, each with its
own padding and divider. A title stays put and footer actions stay reachable
without a sticky workaround.
Use it for feeds and long lists. Leave it off for atomic content: a calendar that scrolls internally is a worse calendar.
Triggers and cursor anchoring
trigger chooses the gesture: click by default, contextMenu for right-click
and long-press, or an array to accept both.
Tagged: accessibility
<template>
<div class="max-w-sm">
<!-- `contextMenu` is right-click on desktop and long-press on touch;
`placement="cursor"` anchors the panel where the pointer was. -->
<BbPopover placement="cursor" trigger="contextMenu" :width="220">
<template #activator="{ props }">
<button
v-bind="props"
class="grid h-24 w-full place-items-center rounded-[var(--bb-radius)] border border-dashed border-[color:var(--bb-border)] text-sm opacity-70"
type="button"
>
Right-click (or long-press) this article
</button>
</template>
<template #header>
<strong class="text-sm">Tags</strong>
</template>
<div class="grid gap-2">
<BbCheckbox
v-for="tag in choices"
:id="`popover-context-${tag.replace(' ', '-')}`"
:key="tag"
v-model="selected"
:label="tag"
name="articleTags"
:true-value="tag"
/>
</div>
</BbPopover>
<p class="mt-2 text-sm opacity-70">
Tagged: {{ selected.length ? selected.join(', ') : 'nothing yet' }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckbox, BbPopover } from 'bitboss-ui';
import { tags } from '~/demo-data';
const choices = tags.slice(0, 4);
const selected = ref<string[]>(['accessibility']);
</script>
Pair contextMenu with placement="cursor" and the panel anchors where the
pointer was rather than on the element.
Keep the distinction: a plain list of actions is
BbDropdown. A context popover earns its place
when the surface holds controls the user operates in place.
Driving it from outside
v-model opens and closes the popover: write true to open and false to
close. It writes the model back on every dismiss, so reading it stays accurate.
Panel is closed.
<template>
<div class="flex flex-wrap items-center gap-3">
<!-- The trigger already exists in this toolbar, so it stays where it is
and the popover points at it with `activator` instead of wrapping it. -->
<BbButton
ref="triggerRef"
icon="lucide:settings"
size="sm"
variant="outline"
>
Workspace settings
</BbButton>
<BbPopover v-model="open" :activator="trigger" :width="260">
<div class="grid gap-3">
<BbSwitch
id="popover-external-digest"
v-model="digest"
label="Weekly digest"
name="digest"
/>
<div class="flex justify-end">
<!-- No exposed close() in v3: writing the model is the API. -->
<BbButton size="sm" variant="ghost" @click="open = false">
Done
</BbButton>
</div>
</div>
</BbPopover>
<p class="text-sm opacity-70">Panel is {{ open ? 'open' : 'closed' }}.</p>
</div>
</template>
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { BbButton, BbPopover, BbSwitch } from 'bitboss-ui';
const trigger = useTemplateRef('triggerRef');
const open = ref(false);
const digest = ref(true);
</script>
activator is for a trigger that already exists somewhere else, like a toolbar
button or a table cell. Pass the element or the component ref and the popover
attaches the listeners and the ARIA itself. There is no props to spread, and
the activator slot is not rendered.
Coming from v2
popoverRef.value.show(), .close() and .isOpen are gone, replaced by
v-model. In an untyped template the leftover call is undefined at runtime
rather than a compile error, which makes it the quietest break on this
component.
The mobile sheet
Below 768px a popover opens as a bottom-anchored, draggable BbOffCanvas sheet
instead of a floating panel.
<template>
<div class="flex flex-wrap items-center gap-2">
<!-- Default: a floating panel above 768px, a bottom sheet below it.
Narrow the window and open it again to see the surface swap. -->
<BbPopover :off-canvas-props="{ title: 'Sort orders' }" :width="240">
<template #activator="{ props }">
<BbButton
v-bind="props"
prepend:icon="lucide:arrow-up-down"
variant="outline"
>
Adaptive
</BbButton>
</template>
<BbRadioGroup
id="popover-sheet-sort"
v-model="sort"
:items="options"
legend="Sort by"
name="sortAdaptive"
/>
</BbPopover>
<!-- Same content, pinned to the floating panel at every width. -->
<BbPopover :adaptive="false" :width="240">
<template #activator="{ props }">
<BbButton
v-bind="props"
prepend:icon="lucide:arrow-up-down"
variant="ghost"
>
Always a panel
</BbButton>
</template>
<BbRadioGroup
id="popover-panel-sort"
v-model="sort"
:items="options"
legend="Sort by"
name="sortPanel"
/>
</BbPopover>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbPopover, BbRadioGroup } from 'bitboss-ui';
const options = ['Newest first', 'Oldest first', 'Highest total'];
const sort = ref<string | null>('Newest first');
</script>
That is the shipped behaviour, and the header, default and footer slots
carry over unchanged. Pass :adaptive="false" to keep the floating panel at
every width.
The part that catches people: width is ignored in sheet mode. A sheet
sizes itself. Tune it with off-canvas-props, which merges over the defaults
side: 'bottom', size: 'auto', draggable: true.
The surface is decided when the panel opens and frozen until it closes, so resizing the window never swaps a live panel into a sheet.
Styling the floating and sheet surfaces
The panel takes its paint from the theme tokens and gives you three class hooks.
.bb-popover .bb-popover__header {
padding: 12px;
font-weight: 600;
}
.bb-popover .bb-popover__content {
padding: 12px;
}
.bb-popover .bb-popover__footer {
background-color: var(--bb-muted);
}
Every BbPopover in the project carries those hooks, so put your own class on
the popover and scope the rules to it rather than restyling .bb-popover
globally.
Those hooks and your class live on the floating panel only. Below 768px the
popover is a BbOffCanvas sheet that neither reaches: a class on the popover
lands on an invisible positioning wrapper the sheet does not have, though a
data-* attribute still gets there. When a look has to hold on a phone, set it
through pt. pt:panel is the bordered box on desktop and the sheet itself on
a phone, and pt:header, pt:content and pt:footer follow the bands into the
sheet.
Keep the look off pt:root. Padding, a border or a background there does not
style the box, it draws a frame around it, and a click on that frame counts as
outside and closes the popover. Desktop geometry on pt:panel — rounded-md,
w-64, px-1 — reshapes the sheet too: correct it for the phone with
pt:sheet, which wins there. And keep overflow-hidden off a panel that can
become a sheet, or a gap shows under it while it is dragged.
Coming from v2
theme is gone. Colour comes from --bb-panel, --bb-border, --bb-radius
and --bb-shadow.