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 themBbHoverCard: the panel previews what a link points at and should open on hover, not on clickBbDropdown: 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
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
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>
<!-- @show reseeds the draft, so a cancelled edit is gone on the next open.
On a phone the sheet's own × would sit above Cancel, so it goes. -->
<BbPopover
:off-canvas-props="{ hideClose: true, title: 'Rename report' }"
:width="260"
@show="reseed"
>
<template #activator="{ props }">
<BbButton
v-bind="props"
icon="lucide:pencil"
size="sm"
variant="ghost"
>
Rename report
</BbButton>
</template>
<!-- The floating panel has 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);
function reseed() {
draft.value = 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 floating 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 and the page behind
Opening moves focus to the first focusable element in the panel; closing returns it to the trigger.
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>
When that first element is not the one the reader came for, point
focus-target at the right one with a CSS selector.
The page behind stays live. A click outside closes the panel and still reaches
what was clicked (focus stays there), and Tab past the last control leaves
the panel and closes it.
Set modal when the reader must
finish or dismiss the panel first: the page goes inert, Tab stays inside, and
an outside click only closes it. The phone sheet is modal either way.
Never open the popover from a @focus handler on the trigger: closing hands
focus back to it, which opens the panel again.
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 (4)padding: the gap the panel keeps from the page edge (10)boundary: an element, ref or selector the panel stays inside alongside its trigger, usually a sidebar or a table. Away from the trigger it may pass that edge; only the viewport flips it
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>
<div class="flex items-center gap-3">
<BbPopover scrollable :width="300">
<template #activator="{ props }">
<BbButton v-bind="props" prepend:icon="lucide:bell" variant="outline">
Order updates · {{ unread }}
</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>
<ul class="m-0 grid list-none gap-3 p-0">
<li
v-for="item in items"
:key="item.reference"
class="flex items-start gap-2 text-sm"
:class="{ 'opacity-60': read.has(item.reference) }"
>
<span
class="mt-1.5 size-2 shrink-0 rounded-full"
:class="read.has(item.reference) ? '' : 'bg-(--bb-primary)'"
/>
<span class="grid">
<span class="font-medium">{{ item.reference }}</span>
<span class="text-xs opacity-70">{{ item.status }}</span>
</span>
</li>
</ul>
<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>
</div>
</template>
<script setup lang="ts">
import { computed, 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>());
const unread = computed(() => items.length - read.size);
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.
Designing for keyboard-only users
Focus order, visible rings and skip links: the three checks we now run on every release before it ships.
Right-click or long-press the article to tag it.
<template>
<div class="w-full 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 }">
<article
v-bind="props"
class="grid gap-2 rounded-(--bb-radius) border border-(--bb-border) p-3 text-left"
tabindex="0"
>
<h3 class="m-0 text-sm font-medium">Designing for keyboard-only users</h3>
<p class="m-0 line-clamp-2 text-xs opacity-70">
Focus order, visible rings and skip links: the three checks we now run
on every release before it ships.
</p>
<div class="flex min-h-5 flex-wrap gap-1">
<BbBadge v-for="tag in selected" :key="tag" size="sm" variant="secondary">
{{ tag }}
</BbBadge>
</div>
</article>
</template>
<!-- Controls the reader operates in place: that is what makes this a
popover and not a context menu. -->
<BbCheckboxGroup
id="popover-context-tags"
v-model="selected"
:items="choices"
legend="Tags"
list-layout="vertical"
name="articleTags"
/>
</BbPopover>
<p class="mt-2 mb-0 text-xs opacity-70">Right-click or long-press the article to tag it.</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbCheckboxGroup, 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. Any control can write it, like the Change link below, and the popover
writes the model back on every dismiss, so reading it stays accurate.
Weekly digest is on.
<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>
<p class="m-0 text-sm">
Weekly digest is {{ digest ? 'on' : 'off' }}.
<!-- Any other control can open it: writing `true` is the whole API, and
the click that wrote it does not count as a click outside. -->
<BbButton size="sm" variant="link" @click="open = true">
Change
</BbButton>
</p>
<!-- On a phone the footer's Done already closes the sheet, so no ×. -->
<BbPopover
v-model="open"
:activator="trigger"
:off-canvas-props="{ hideClose: true, title: 'Workspace settings' }"
: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(): writing `false` closes it. -->
<BbButton size="sm" variant="ghost" @click="open = false">
Done
</BbButton>
</div>
</div>
</BbPopover>
</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.
The body is lazy: it renders on the first open. Set
eager to render it at mount, for a
chart that must measure itself or a test that queries a closed panel.
@shown fires once the open transition has run and focus is inside the
panel, and @hidden once the panel has closed. Both wait for the transition on the floating panel and on the sheet,
so they are the safe place to measure or to move focus yourself.
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.
One trigger per row
A rename button on every row means two component instances per row before
anyone opens anything. The v-bb-popover directive costs what a plain button
costs: the trigger carries aria-haspopup="dialog" and nothing else until the
click that opens it. From then on it is the full popover, focus handling
included.
- Q3 revenue
- Churn by plan
- Support backlog
<template>
<ul class="m-0 grid max-w-sm list-none gap-1 p-0 text-sm">
<li
v-for="report in reports"
:key="report.id"
class="flex items-center justify-between gap-2"
>
<span>{{ report.title }}</span>
<!-- The inner quotes make 'rename-report' a name; without them it is
an expression. The form has its own Cancel, so `hideClose` leaves
the phone sheet one way out. -->
<BbButton
v-bb-popover:bottom-end="{
portal: 'rename-report',
data: { report },
width: 260,
offCanvasProps: { hideClose: true },
}"
:aria-label="`Rename ${report.title}`"
icon="lucide:pencil"
size="sm"
variant="ghost"
/>
</li>
</ul>
<!-- Declared once. It renders only inside the popover that opens. -->
<BbPopoverPortal v-slot="{ data, close }" name="rename-report">
<form class="grid gap-2" @submit.prevent="rename(data.report, close)">
<BbTextInput
compact
label="Report name"
:model-value="draftOf(data.report)"
@update:model-value="drafts[data.report.id] = $event ?? ''"
/>
<div class="flex justify-end gap-2">
<BbButton size="sm" type="button" variant="ghost" @click="discard(data.report, close)">
Cancel
</BbButton>
<BbButton :disabled="!draftOf(data.report).trim()" size="sm" type="submit">
Save
</BbButton>
</div>
</form>
</BbPopoverPortal>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbButton, BbPopoverPortal, BbTextInput, vBbPopover } from 'bitboss-ui';
type Report = { id: number; title: string };
const reports = ref<Report[]>([
{ id: 1, title: 'Q3 revenue' },
{ id: 2, title: 'Churn by plan' },
{ id: 3, title: 'Support backlog' },
]);
// One draft per row, so each popover opens on its own row's title.
const drafts = reactive<Record<number, string>>({});
const draftOf = (report: Report) => drafts[report.id] ?? report.title;
function discard(report: Report, close: () => void) {
drafts[report.id] = report.title;
close();
}
/** Commit first, dismiss second. */
function rename(report: Report, close: () => void) {
report.title = draftOf(report).trim();
discard(report, close);
}
</script>
A directive has no slot, so the body is a portal. Declare a BbPopoverPortal
once and open it by name, with the name in inner quotes
(v-bb-popover="'rename-report'"); a bare rename-report is a subtraction and
opens nothing. The portal's slot gets { data, close }, where data is the
binding's data. The argument is the placement (v-bb-popover:bottom-end), and
the object form takes any BbPopover prop beside portal and data.
Passing a component instead (v-bb-popover="RenameForm") renders it with
the keys of data as its props, but it has no way to close the popover. Content
that needs close is a named portal or a BbPopover in the template.
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"
list-layout="vertical"
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="outline"
>
Always a panel
</BbButton>
</template>
<BbRadioGroup
id="popover-panel-sort"
v-model="sort"
:items="options"
legend="Sort by"
list-layout="vertical"
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 sheet shows a close button × that the floating panel does not have, so a
popover holding a form always has a way out on a phone. When your own footer
already closes it, hide the × with :off-canvas-props="{ hideClose: true }", as
the rename and settings demos above do.
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 four class hooks:
.bb-popover__panel, the bordered box, and __header, __content and
__footer inside it. Every BbPopover in the project carries them, so put your
own class on the popover and scope the rules to it.
<BbPopover class="team-popover">…</BbPopover>
.team-popover .bb-popover__header {
padding: 12px;
font-weight: 600;
}
.team-popover .bb-popover__content {
padding: 12px;
}
.team-popover .bb-popover__footer {
background-color: var(--bb-muted);
}
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.