Use it for
Use BbSelectPopover when someone chooses a value from a trigger you design: a
status pill, filter chip, avatar stack or table cell. It provides the select
engine without drawing a form field.
Use something else when
BbSelect: you need a labelled field with guidance and validationBbRadioGroup: a short exclusive choice should remain visibleBbDropdown: the rows run commandsBbPopover: the panel contains arbitrary content instead of options
Also see: Options and items, Fetching data, Coherence.
Item mapping, item-props, custom rows and option identity follow
BbSelect.
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.
Build a reliable trigger
Spread the activator slot's props onto the focusable element, give it an explicit
accessible name and derive its label from application state.
ENG-412
Checkout rounds VAT twice on discounted carts
Assign someone before moving this to review.
<template>
<article
class="flex max-w-md flex-col gap-3 rounded-(--bb-radius) border border-(--bb-border) p-4"
>
<header>
<p class="text-xs opacity-60">ENG-412</p>
<p class="text-sm font-medium">
Checkout rounds VAT twice on discounted carts
</p>
</header>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="w-20 text-xs opacity-60">Assignee</span>
<!--
The popover has no message area: the message is yours, and its id
goes to `aria-describedby`, which lands on the trigger and on the
search field. `has-errors` sets `aria-invalid` and marks the field.
-->
<BbSelectPopover
id="board-assignee"
v-model="assignee"
:aria-describedby="current ? undefined : 'board-assignee-error'"
compact
:has-errors="!current"
:item-height="36"
item-text="fullName"
item-value="id"
:items="engineers"
:width="260"
>
<!--
`props` carries the popover reference and the open/close handlers, so
it has to land on the focusable element itself — here the badge's
button, not the badge around it. `shown` is how the trigger shows it
is expanded.
-->
<template #activator="{ props, shown }">
<BbBadge
append:icon="lucide:chevron-down"
size="lg"
:variant="shown ? 'secondary' : 'outline'"
>
<BbBadgeButton
:aria-label="`Assignee: ${current?.fullName ?? 'unassigned'}`"
v-bind="props"
>
<span class="inline-flex items-center gap-1.5">
<BbAvatar
v-if="current"
:alt="current.fullName"
size="16"
:src="current.avatar"
/>
{{ current?.fullName ?? 'Unassigned' }}
</span>
</BbBadgeButton>
</BbBadge>
</template>
<template #option="{ item, text }">
<span class="flex items-center gap-2 text-sm">
<BbAvatar :alt="text" size="24" :src="item.avatar" />
{{ text }}
</span>
</template>
</BbSelectPopover>
<p
v-if="!current"
id="board-assignee-error"
class="basis-full ps-22 text-xs text-(--bb-danger)"
>
Assign someone before moving this to review.
</p>
</div>
</article>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbAvatar, BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import { users } from '~/demo-data';
const engineers = users.filter((user) => user.team === 'Engineering');
const assignee = ref<number | null>(null);
// The label is resolved from our own data, never from the slot's `text`. It is
// the same amount of code, and it stays correct the day `items` becomes a
// provider that has not run yet.
const current = computed(() =>
engineers.find((user) => user.id === assignee.value)
);
</script>
Do not label the trigger from slot text or selectedOptions: provider-backed
options are unresolved until they load, so the trigger can start blank. Reflect
shown visually when the panel is open.
The popover has no message area and never joins a BbForm. For an invalid value, render the message next to your trigger and pass its id to aria-describedby; has-errors sets aria-invalid and marks the search field. The pill above does this while nobody is assigned.
Coming from v2
As on BbSelect, clicking the current single value now keeps it selected and
closes the panel. Use the slot clear callback for an explicit reset.
Multiple selection
Seed an array when using multiple, then summarize it on the trigger you own.
<template>
<div class="flex items-center gap-2">
<span class="text-xs font-medium opacity-60">Filters</span>
<BbSelectPopover
id="board-reviewers"
v-model="reviewers"
compact
item-text="fullName"
item-value="id"
:item-height="36"
:items="reviewerPool"
:max="3"
multiple
:width="240"
>
<!--
There are no chips here: the trigger is yours, so summarising the
selection is yours too.
-->
<template #activator="{ props }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:users"
size="lg"
:variant="reviewers.length > 0 ? 'secondary' : 'outline'"
>
<BbBadgeButton :aria-label="reviewersName" v-bind="props">
<span class="inline-flex items-center gap-1.5">
Reviewers
<!-- An avatar stack says who, not only how many. -->
<span v-if="picked.length" class="flex -space-x-1">
<BbAvatar
v-for="user in picked"
:key="user.id"
:alt="user.fullName"
class="ring-2 ring-(--bb-panel)"
size="16"
:src="user.avatar"
/>
</span>
</span>
</BbBadgeButton>
</BbBadge>
</template>
<template #option="{ item, text }">
<span class="flex items-center gap-2 text-sm">
<BbAvatar :alt="text" size="24" :src="item.avatar" />
{{ text }}
</span>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbAvatar, BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import { users } from '~/demo-data';
const reviewerPool = users.filter((user) => user.role === 'Developer');
// `multiple` needs an array from the first render, exactly as on BbSelect.
const reviewers = ref<number[]>([4, 5]);
// Resolved from our own list, so the stack is right before any panel opens.
const picked = computed(() =>
reviewerPool.filter((user) => reviewers.value.includes(user.id))
);
// The avatars carry no text, so the name spells out who is picked.
const reviewersName = computed(() =>
picked.value.length
? `Reviewers: ${picked.value.map((user) => user.fullName).join(', ')}`
: 'Reviewers: any'
);
</script>
max limits the count without locking selected options.
Panel actions and short lists
Use header to name the panel and footer for clear or done actions.
Bind the scope's titleId as the title's id: on the phone sheet it names the
dialog.
<template>
<div class="flex items-center gap-2">
<span class="text-xs font-medium opacity-60">Destination</span>
<BbSelectPopover
id="trip-destination"
v-model="destination"
compact
item-text="name"
item-value="id"
:anchor="pill"
:items="europeanCities"
empty-text="No city matches that name."
placement="bottom-start"
:width="260"
>
<!--
`props` lands on the inner button, which is narrower than the pill
around it. `anchor` points positioning at the pill, so the panel
lines up with what people see.
-->
<template #activator="{ props }">
<BbBadge
ref="pill"
append:icon="lucide:chevron-down"
prepend:icon="lucide:map-pin"
size="lg"
variant="outline"
>
<BbBadgeButton
:aria-label="`Destination: ${destinationLabel}`"
v-bind="props"
>
{{ destinationLabel }}
</BbBadgeButton>
</BbBadge>
</template>
<!-- `titleId` on the title: on the phone sheet it names the dialog. -->
<template #header="{ titleId }">
<span :id="titleId" class="text-sm font-medium">Popular destinations</span>
</template>
<!--
Your trigger is not a form field, so it has no clear button of its
own; `clear` from the panel scope gives people an explicit reset.
-->
<template #footer="{ clear, close }">
<div class="flex w-full items-center gap-2">
<BbButton class="me-auto" size="xs" variant="ghost" @click="clear">
Clear
</BbButton>
<BbButton size="xs" variant="primary" @click="close">Done</BbButton>
</div>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbButton, BbSelectPopover } from 'bitboss-ui';
import { cities, countryByCode } from '~/demo-data';
const europeanCities = cities.filter(
(city) => countryByCode[city.countryCode]?.region === 'Europe'
);
// Seeded with a city near the top of the list, so the checked row is in view
// when the panel opens.
const destination = ref<string | null>('paris');
const pill = ref<InstanceType<typeof BbBadge> | null>(null);
const destinationLabel = computed(
() =>
europeanCities.find((city) => city.id === destination.value)?.name ??
'Add destination'
);
</script>
The panel slots expose clear, close, focus, query and
selectedOptions. The clearable prop puts a clear button inside the
search field instead, so it does nothing on a list without one.
An array of fewer than six options opens without a search field, unless you listen to option:add; a provider always keeps it. disable-writing drops it on any list, as it does for the six statuses below. Without the field there is no filtering and no option:add.
<template>
<div class="flex items-center gap-2">
<span class="text-xs font-medium opacity-60">Status</span>
<!--
Six options do not need a search field. `disable-writing` removes it,
and with it the query, the local filter and `option:add`.
-->
<BbSelectPopover
id="order-status-pill"
v-model="status"
compact
disable-writing
:items="statusOptions"
:width="180"
>
<template #activator="{ props, shown }">
<BbBadge
append:icon="lucide:chevron-down"
size="lg"
:variant="shown ? 'secondary' : 'outline'"
>
<BbBadgeButton
:aria-label="`Order status: ${statusLabel}`"
v-bind="props"
>
{{ statusLabel }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import { orderStatusLabels, orderStatuses } from '~/demo-data';
const statusOptions = orderStatuses.map((value) => ({
value,
label: orderStatusLabels[value],
}));
const status = ref<string | null>('processing');
const statusLabel = computed(
() =>
statusOptions.find((option) => option.value === status.value)?.label ??
'Set status'
);
</script>
Coming from v2allowWriting → disableWriting
header and footer replace options:prepend, options:append and their
:outer variants. disable-writing replaces and inverts allow-writing, and
filter-by accepts string[] or false.
Fetching and dependent pickers
A provider receives one object, { query, reason, modelValue, signal }. Return
selected rows on every call whose reason is not 'search' so values remain resolvable.
<template>
<div class="flex items-center gap-2">
<span class="text-xs font-medium opacity-60">Ship to</span>
<BbSelectPopover
id="shipping-city"
v-model="city"
compact
item-text="name"
item-value="id"
:items="searchCities"
loading-text="Searching…"
empty-text="No city matches that name."
:filter-by="false"
placement="bottom-start"
:query-debounce-time="300"
stash
:width="260"
>
<template #activator="{ props, loading }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:truck"
size="lg"
variant="outline"
>
<BbBadgeButton :aria-label="`Ship to: ${cityLabel}`" v-bind="props">
{{ cityLabel }}{{ loading ? ' …' : '' }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import type { BbSelectPopoverItemsContext } from 'bitboss-ui';
import type { City } from '~/demo-data';
import { cities, delay, matches } from '~/demo-data';
const city = ref<string | null>('lisbon');
// The pill reads from the same fixture the provider draws on, so it is right on
// first paint — before the provider has run even once.
const cityLabel = computed(
() => cities.find((entry) => entry.id === city.value)?.name ?? 'Pick a city'
);
const popular = cities.slice(0, 8);
// Stands in for `api.cities.search(...)`. It matches the query itself, so the
// popover sets `:filter-by="false"` and shows the answer as it is: every call
// applies the query, not only the ones where the user typed. Calls the
// component makes by itself (the first open) arrive with a `reason` other than
// `'search'`: add the selected city, so it resolves wherever it sits.
const searchCities = ({
query,
reason,
modelValue,
}: BbSelectPopoverItemsContext<string | null>): Promise<City[]> => {
const trimmed = query.trim();
const found = trimmed
? cities.filter((entry) => matches(entry.name, trimmed))
: popular;
const current =
reason === 'search'
? []
: cities.filter(
(entry) => entry.id === modelValue && !found.includes(entry)
);
return delay([...current, ...found], 400);
};
</script>
Unlike BbSelect, a seeded model does not force an eager provider call. Keep the
trigger independent of loaded options instead of adding prefill: true only to
render its label. Provider results pass through the local substring filter unless
you set :filter-by="false", as this demo does: its provider matches
the query, accents aside, on every call.
Coming from v2
prefill="focus" becomes 'interaction'. prefill: false is search-first;
remove it to load on first open. no-data-text and #no-data are empty-text
and #empty.
Use dependencies to refetch and enforce-coherence to remove values absent
from the refreshed options. Switch the team to Design and the owner filter resets.
<template>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-medium opacity-60">Filters</span>
<BbSelectPopover
id="filter-team"
v-model="team"
compact
disable-writing
:items="filterTeams"
:width="180"
>
<template #activator="{ props }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:folder"
size="lg"
variant="outline"
>
<BbBadgeButton :aria-label="`Team: ${team}`" v-bind="props">
{{ team }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
<!--
No `prefill` prop anywhere: `enforce-coherence` over a seeded model has
to load to judge the value, so the first fetch happens on mount by
itself. That also means one request per instance — cheap for a filter
bar, expensive for fifty table rows.
-->
<BbSelectPopover
id="filter-owner"
v-model="owner"
compact
:dependencies="[team]"
:deps-debounce-time="150"
enforce-coherence
item-text="fullName"
item-value="id"
:items="loadOwners"
empty-text="Nobody on this team matches."
:width="220"
>
<template #activator="{ props }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:user-check"
size="lg"
variant="outline"
>
<BbBadgeButton
:aria-label="`Owner: ${ownerLabel}`"
v-bind="props"
>
{{ ownerLabel }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import type { Team, User } from '~/demo-data';
import { delay, users } from '~/demo-data';
const filterTeams: Team[] = ['Engineering', 'Design', 'Support'];
const team = ref<Team>('Engineering');
const owner = ref<number | null>(4);
const ownerLabel = computed(
() => users.find((user) => user.id === owner.value)?.fullName ?? 'Any owner'
);
const loadOwners = (): Promise<User[]> =>
delay(
users.filter((user) => user.team === team.value),
400
);
</script>
Enforcement validates the seeded model on its first load. Each provider-backed instance therefore makes its own request; an empty response can clear a valid selection.
One picker per table row
Resolve shared options once, pass the same array to every row and keep edits in a draft map keyed by row id.
| Reference | Customer | Total | Status |
|---|---|---|---|
| ORD-2026-0496 | Elif Yilmaz | €418.00 | |
| ORD-2026-0474 | Amara Okonkwo | €777.90 | |
| ORD-2026-0447 | Mei Lin Chen | €458.00 | |
| ORD-2026-0417 | Diego Ramirez | €1,298.00 |
<template>
<div class="flex flex-col gap-3">
<table class="w-full max-w-xl text-sm">
<caption class="sr-only">
Orders, with an editable status column
</caption>
<thead>
<tr class="text-left text-xs uppercase opacity-60">
<th class="py-1 pe-3" scope="col">Reference</th>
<th class="hidden py-1 pe-3 sm:table-cell" scope="col">Customer</th>
<th class="py-1 text-end" scope="col">Total</th>
<th class="py-1 ps-4" scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="order in rows" :key="order.id">
<td class="py-1 pe-3 font-mono text-xs whitespace-nowrap">{{ order.reference }}</td>
<td class="hidden py-1 pe-3 sm:table-cell">
{{ userById[order.customerId]?.fullName }}
</td>
<td class="py-1 text-end tabular-nums">{{ money(order) }}</td>
<td class="py-1 ps-4">
<!--
The same `statusOptions` array for every row, resolved once
here: with a provider each row would own a request. The value
is bound down and committed up into `drafts`: the row object
is never written to, so a refetch cannot discard an edit.
-->
<BbSelectPopover
:id="`order-${order.id}-status`"
compact
disable-writing
:items="statusOptions"
:model-value="statusOf(order)"
:width="180"
@update:model-value="drafts[order.id] = $event as OrderStatus"
>
<template #activator="{ props }">
<BbBadge
append:icon="lucide:chevron-down"
:variant="draft(order) ? 'secondary' : 'outline'"
>
<BbBadgeButton
:aria-label="`Status of ${order.reference}: ${orderStatusLabels[statusOf(order)]}`"
v-bind="props"
>
{{ orderStatusLabels[statusOf(order)] }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</td>
</tr>
</tbody>
</table>
<div class="flex items-center gap-2">
<BbButton :disabled="dirtyCount === 0" size="sm" @click="save">
Save{{ dirtyCount > 0 ? ` (${dirtyCount})` : '' }}
</BbButton>
<span class="text-xs opacity-60">
Edits stay here until you save.
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbButton, BbSelectPopover } from 'bitboss-ui';
import type { Order, OrderStatus } from '~/demo-data';
import {
clone,
orderStatusLabels,
orders,
orderStatuses,
userById,
} from '~/demo-data';
const statusOptions = orderStatuses.map((value) => ({
value,
label: orderStatusLabels[value],
}));
// One order in each stage, so a changed draft stands out against its neighbours.
const rows = ref<Order[]>(clone([16, 12, 7, 0].map((index) => orders[index]!)));
const money = (order: Order) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: order.currency,
}).format(order.total);
// Page-level, keyed by row id. Never `v-model="order.status"`.
const drafts = ref<Record<number, OrderStatus>>({});
const statusOf = (order: Order): OrderStatus =>
drafts.value[order.id] ?? order.status;
const draft = (order: Order): OrderStatus | undefined => drafts.value[order.id];
const dirtyCount = computed(() => Object.keys(drafts.value).length);
const save = () => {
rows.value = rows.value.map((order) => {
const next = drafts.value[order.id];
return next ? { ...order, status: next } : order;
});
drafts.value = {};
};
</script>
Give each picker its own model. One shared ref lets every instance judge and overwrite the same value; one provider per row makes request count follow row count.
Width, placement and mobile
Set a concrete width for compact triggers; avoid 'auto', which expands
against the viewport on this fixed-positioned panel. placement="bottom-start" lines the panel up with the trigger's leading edge.
When props lands on a button inside a wider pill, pass the pill's ref to anchor so the panel lines up with what people see, as the trip destination picker under panel actions does. To open the popover from an element rendered elsewhere, pass that element's ref to activator instead of using the slot.
<template>
<div class="flex flex-wrap items-center gap-3">
<!-- A chip trigger: 160px matches the labels, where the 200 default would float. -->
<BbSelectPopover
id="width-chip"
v-model="priority"
compact
disable-writing
:items="priorities"
placement="bottom-start"
:width="160"
>
<template #activator="{ props }">
<BbBadge append:icon="lucide:chevron-down" size="lg" variant="outline">
<BbBadgeButton
:aria-label="`Priority: ${priority ?? 'none'}`"
v-bind="props"
>
{{ priority ?? 'Priority' }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
<!-- Long labels, opened upwards, and no flipping back down. -->
<BbSelectPopover
id="width-wide"
v-model="language"
disable-flip
:items="languages"
:offset="8"
placement="top-start"
:width="280"
>
<template #activator="{ props }">
<BbButton
append:icon="lucide:chevron-up"
:aria-label="`Interface language: ${language ?? 'not set'}`"
size="sm"
v-bind="props"
variant="outline"
>
{{ language ?? 'Interface language' }}
</BbButton>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbBadgeButton, BbButton, BbSelectPopover } from 'bitboss-ui';
import { languages, priorities } from '~/demo-data';
const priority = ref<string | null>('High');
const language = ref<string | null>(null);
</script>
Below the mobile breakpoint the panel becomes a sheet and placement props stop
applying. Style the visible surface with pt:panel, the search field with
pt:input and sheet-only corrections with pt:sheet.
Coming from v2flip → disableFlip
Use disable-flip instead of flip. hide-arrow and arrow-padding are
removed because this panel has no arrow. Classes: bb-select-popover__search-input is
bb-select-popover__input, bb-select-popover__search-clear is
bb-select-popover__clear and bb-select-popover-offcanvas is
bb-select-popover__sheet.