Use it for
Use BbDropdown to put several commands or navigation links behind one trigger:
row actions, an account menu or a context menu. Describe rows with items; the
component owns ARIA, focus and keyboard behaviour.
Use something else when
BbButton: there is one actionBbDropdownButton: one action dominates and the rest are close alternativesBbSelect: the user chooses a form value
Also see: Options and items.
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 row actions menu
Spread the activator props onto a focusable trigger and pass the actions as
data.
ORD-2026-0417 — no action yet
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdown :items="items">
<template #activator="{ props }">
<BbButton
v-bind="props"
append:icon="lucide:chevron-down"
variant="outline"
>
Order actions
</BbButton>
</template>
</BbDropdown>
<p class="text-sm opacity-70" role="status">{{ last }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropdown } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { orders } from '~/demo-data';
const order = orders[0]!;
const last = ref(`${order.reference} — no action yet`);
// Rows are data: `label` is what the row says, and it does something (onClick)
// or goes somewhere (href), never both. Plain action rows need no `value`.
const items: BbDropdownItem[] = [
{
label: 'Download invoice',
onClick: () => (last.value = `Invoice for ${order.reference} downloaded`),
},
{ label: 'Track shipment', href: '#tracking' },
{
label: 'Add internal note',
onClick: () => (last.value = `Note added to ${order.reference}`),
},
];
</script>
Use a real button and name icon-only triggers. Style the trigger inside
#activator: a class on <BbDropdown> lands on the menu. There is no open
state to bind; @show and
@hide tell you when it opens and
closes. The default slot is only for <BbDropdownGroup> companions: a trigger
placed there opens nothing.
Choose the right item type
Use onClick for commands and href or to for navigation, so links keep
native browser behaviour.
Q2 revenue review.pdf
2.4 MB · shared with the Finance team
<template>
<div
class="flex max-w-md items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) px-3 py-2"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{{ file.name }}</p>
<p class="text-xs text-(--bb-text-muted)" role="status">
{{ formatFileSize(file.size) }} · {{ status }}
</p>
</div>
<!-- Delete file's confirm renders in the app's one <BbHost />, at the root. -->
<BbDropdown :items="items" placement="bottom-end" :width="248">
<template #activator="{ props }">
<BbButton
v-bind="props"
aria-label="File actions"
icon="lucide:ellipsis"
size="sm"
variant="ghost"
/>
</template>
</BbDropdown>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropdown, useConfirm } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { files, formatFileSize } from '~/demo-data';
const file = files[0]!;
const status = ref('shared with the Finance team');
const { confirm } = useConfirm();
const items: BbDropdownItem[] = [
// An action: it runs and the menu closes.
{
value: 'rename',
label: 'Rename',
'prepend:icon': 'lucide:pencil',
onClick: () => (status.value = 'renamed'),
},
// A link: the row renders a real <a>, so middle-click and "open in new tab"
// work. Navigation never belongs in onClick.
{
value: 'preview',
label: 'Open preview',
'prepend:icon': 'lucide:external-link',
href: '#preview',
},
// A nested array is its own section, drawn with a divider above it.
[
{
value: 'delete',
label: 'Delete file',
description: 'This cannot be undone',
'prepend:icon': 'lucide:trash-2',
variant: 'destructive',
onClick: async () => {
const ok = await confirm({
title: `Delete "${file.name}"?`,
text: 'The file is removed for everyone in the workspace.',
variant: 'destructive',
yes: { text: 'Delete file', 'prepend:icon': 'lucide:trash-2' },
no: 'Keep file',
});
if (ok) status.value = 'deleted';
},
},
],
];
</script>
label is what the row says and description adds a second line. A plain
action or link needs no value; give one to any row something outside the menu
reads, such as a selectable row or a row with its own slot. Gate destructive actions with
useConfirm.
Coming from v2
Rows read label (v2 text) and value (v2 key); groups, new in v3, are
named by id (key in early v3 betas). npx bitboss-ui check --fix and the
ESLint rule rewrite literal rows. Destructive rows use
variant: 'destructive'. In fetched groups, map extra row fields through
itemProps; raw href, icons and disabled fields are otherwise ignored.
Split rows into sections with nested arrays, pin the signed-in user in the
header slot and put shortcut hints in item:append.
Signed in
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdown :items="items" placement="bottom-start" :width="264">
<template #activator="{ props }">
<BbButton
v-bind="props"
append:icon="lucide:chevrons-up-down"
variant="ghost"
>
<span class="flex items-center gap-2">
<BbAvatar :alt="me.fullName" size="24" :src="me.avatar" />
{{ me.firstName }}
</span>
</BbButton>
</template>
<!-- Pinned above the rows: who is signed in, not something to click. -->
<template #header>
<div class="grid px-3 py-2">
<span class="text-sm font-medium">{{ me.fullName }}</span>
<span class="truncate text-xs opacity-70">{{ me.email }}</span>
</div>
</template>
<!-- Shortcut hints ride in `meta`; rows without one render nothing. -->
<template #item:append="{ item }">
<kbd
v-if="item.meta?.keys"
class="font-mono text-xs opacity-60"
>
{{ item.meta.keys }}
</kbd>
</template>
</BbDropdown>
<p class="text-sm opacity-70" role="status">{{ last }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbAvatar, BbButton, BbDropdown } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { userById } from '~/demo-data';
const me = userById[7]!;
const last = ref('Signed in');
const run = (text: string) => () => (last.value = text);
// Each nested array is a section with a divider above it. Sign out sits alone
// at the bottom, where a stray click is least likely.
const items: BbDropdownItem[] = [
[
{
value: 'profile',
label: 'Profile',
'prepend:icon': 'lucide:user',
meta: { keys: '⇧⌘P' },
href: '#profile',
},
{
value: 'settings',
label: 'Settings',
'prepend:icon': 'lucide:settings',
meta: { keys: '⌘,' },
href: '#settings',
},
{
value: 'billing',
label: 'Billing',
'prepend:icon': 'lucide:credit-card',
href: '#billing',
},
],
[
{
value: 'shortcuts',
label: 'Keyboard shortcuts',
'prepend:icon': 'lucide:keyboard',
meta: { keys: '?' },
onClick: run('Opened keyboard shortcuts'),
},
{
value: 'help',
label: 'Help centre',
'prepend:icon': 'lucide:life-buoy',
href: '#help',
},
],
[
{
value: 'sign-out',
label: 'Sign out',
'prepend:icon': 'lucide:log-out',
variant: 'destructive',
meta: { keys: '⇧⌘Q' },
onClick: run('Signed out'),
},
],
];
</script>
Carry the shortcut in meta. The menu only displays it; binding the keys is up to you.
Use the directive for simple menus
When the trigger already exists and the menu is a flat items array, use the globally registered directive.
Team members
25 members
<template>
<div
class="flex max-w-md items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) px-3 py-2"
>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">Team members</p>
<p class="text-xs text-(--bb-text-muted)" role="status">{{ status }}</p>
</div>
<!--
No <BbDropdown> wrapper and no activator slot: the directive attaches
the menu, the ARIA and the keyboard handling to this button. The
argument is a placement shorthand.
-->
<BbButton
v-bb-dropdown:bottom-end="items"
icon="lucide:ellipsis"
size="sm"
variant="ghost"
>
Team member actions
</BbButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton } from 'bitboss-ui';
import type { BbDropdownDirectiveValue } from 'bitboss-ui';
import { users } from '~/demo-data';
const status = ref(`${users.length} members`);
// The directive's own type: a flat list of items, or an object of BbDropdown
// props carrying one. Sections, groups and slots need the component.
const items: BbDropdownDirectiveValue = [
{
label: 'Invite people',
'prepend:icon': 'lucide:user-plus',
onClick: () => (status.value = 'Invitation link copied'),
},
{
label: 'Export member list',
'prepend:icon': 'lucide:download',
onClick: () => (status.value = `${users.length} members exported`),
},
{
label: 'Team settings',
'prepend:icon': 'lucide:settings',
href: '#team-settings',
},
];
</script>
Use the component when you need slots, sections or a <BbDropdownGroup>.
Track async actions
Return a promise from an item's onClick: the activator slot's loading
stays true until it settles, and repeat clicks on that item are ignored.
Catalogue
26 products · last synced at 09:24
<template>
<div
class="flex max-w-md items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) px-3 py-2"
>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">Catalogue</p>
<p class="text-xs text-(--bb-text-muted)" role="status">{{ status }}</p>
</div>
<BbDropdown :items="items" placement="bottom-end" :width="220">
<template #activator="{ props, loading }">
<!--
`loading` is true while any item's async onClick is in flight —
including after the menu has closed. Bind it and the trigger
carries the work.
-->
<BbButton
v-bind="props"
append:icon="lucide:chevron-down"
:loading="loading"
size="sm"
variant="outline"
>
Sync
</BbButton>
</template>
</BbDropdown>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropdown } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { delay, products } from '~/demo-data';
const status = ref(`${products.length} products · last synced at 09:24`);
const items: BbDropdownItem[] = [
{
label: 'Sync catalogue',
'prepend:icon': 'lucide:refresh-cw',
onClick: async () => {
status.value = 'Syncing…';
await delay(null, 1200);
status.value = `${products.length} products · synced just now`;
},
},
{
label: 'Sync history',
'prepend:icon': 'lucide:history',
href: '#history',
},
];
</script>
Coming from v2
The exposed template-ref loading member is removed. Read the flag from the
activator slot.
Add submenus
Give an item its own items array. Keep nesting shallow: the parent row opens
the submenu and cannot also run an action or navigate.
Checkout fails on Safari after a coupon is applied
#482 · Backlog · Bug
<template>
<div
class="flex max-w-md items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) px-3 py-2"
>
<div class="min-w-0 flex-1">
<p class="line-clamp-2 text-sm font-medium">
Checkout fails on Safari after a coupon is applied
</p>
<p class="text-xs text-(--bb-text-muted)" role="status">
#482 · {{ column }} · {{ label }}
</p>
</div>
<BbDropdown :items="items" placement="bottom-end" :width="220">
<template #activator="{ props }">
<BbButton
v-bind="props"
aria-label="Issue actions"
icon="lucide:ellipsis-vertical"
size="sm"
variant="ghost"
/>
</template>
</BbDropdown>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropdown } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
const column = ref('Backlog');
const label = ref('Bug');
// An item that carries its own `items` opens a submenu and does nothing else:
// pairing it with onClick or href is a type error, because the handler would
// never run and the anchor would navigate away mid-open.
const items: BbDropdownItem[] = [
{
value: 'move',
label: 'Move to',
'prepend:icon': 'lucide:columns-3',
items: ['Backlog', 'In progress', 'In review', 'Done'].map((name) => ({
value: `move-${name}`,
label: name,
onClick: () => (column.value = name),
})),
},
{
value: 'label',
label: 'Label',
'prepend:icon': 'lucide:tag',
items: ['Bug', 'Performance', 'Design', 'Docs'].map((name) => ({
value: `label-${name}`,
label: name,
onClick: () => (label.value = name),
})),
},
{ value: 'copy', label: 'Copy link', 'prepend:icon': 'lucide:link', onClick: () => {} },
];
</script>
Desktop uses flyouts; the mobile sheet drills down one level at a time.
Submenus follow the ARIA menu keyboard model. ArrowRight, Enter, Space or a
click open one with focus inside (its search box first, when it has one), and
Escape closes one level and puts focus back on the row that opened it. A
submenu that closes under the pointer hands focus to its row too. Under
dir="rtl" submenus open to the left and the two inline arrows swap. On the
sheet, Escape and the back control go back one level, focus on the row that
opened it; at the top level Escape closes the sheet.
Build selectable groups
Mark a group selectable, then bind its state with <BbDropdownGroup> using the
group's id. The model holds the picked row's value. A single group is a
radio group: clicking the picked row again keeps it, so "Sort by" can never end
up empty.
<template>
<div class="flex flex-col items-start gap-3">
<BbDropdown
id="order-filters"
:items="items"
placement="bottom-start"
:width="230"
>
<template #activator="{ props }">
<BbButton
v-bind="props"
append:icon="lucide:chevron-down"
prepend:icon="lucide:list-filter"
variant="outline"
>
Filter orders
</BbButton>
</template>
<!--
The companion is renderless: it only needs the group's `id` and a
v-model. The options themselves stay in `items`.
-->
<BbDropdownGroup id="sort" v-model="sort" />
<BbDropdownGroup id="channel" v-model="channel" />
<BbDropdownGroup id="status" v-model="statuses" />
</BbDropdown>
<!-- The chips read the labels the menu already resolved: no lookup table. -->
<ul class="flex flex-wrap gap-1.5 text-xs" aria-label="Active filters">
<li
v-for="chip in chips"
:key="chip.group"
class="rounded-full border border-(--bb-border) px-2.5 py-0.5"
>
<span class="text-(--bb-text-muted)">{{ chip.group }}:</span>
{{ chip.text }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
BbButton,
BbDropdown,
BbDropdownGroup,
useBbDropdownContext,
} from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { orderStatusLabels, orderStatuses } from '~/demo-data';
const sort = ref('recent');
const channel = ref<string | null>('web');
const statuses = ref<string[]>(['pending']);
// `selectable` alone is a radio group: clicking the picked row again keeps
// it. `clearable` lets that click empty the group instead, and `multiple`
// makes checkboxes that keep the menu open while you toggle.
const items: BbDropdownItem[] = [
{
id: 'sort',
label: 'Sort by',
selectable: true,
items: [
{ value: 'recent', label: 'Most recent' },
{ value: 'total', label: 'Total (high to low)' },
{ value: 'customer', label: 'Customer name' },
],
},
{
id: 'channel',
label: 'Channel',
selectable: true,
clearable: true,
items: [
{ value: 'web', label: 'Web shop' },
{ value: 'marketplace', label: 'Marketplace' },
{ value: 'phone', label: 'Phone order' },
],
},
{
id: 'status',
label: 'Status',
selectable: true,
multiple: true,
items: orderStatuses.slice(0, 4).map((status) => ({
value: status,
label: orderStatusLabels[status],
})),
},
];
// Read-only: a chip shows a pick, the group's v-model changes it.
const { groups } = useBbDropdownContext('order-filters');
const chips = computed(() =>
Object.values(groups.value).map((group) => ({
group: group.label,
text:
group.selectedOptions.map((option) => option.label).join(', ') ||
(group.multiple ? 'Any' : 'None'),
}))
);
</script>
A pick in a single group closes the menu, wherever the group sits (in a
submenu, beside other groups) and whatever else the menu holds; a multiple
group stays open while you toggle. close-on-select
overrides both: false keeps a settings menu of several radios open, true
closes after every change.
Add clearable: true when "none" is a valid answer, as on "Channel": that
click then empties the group and fires item:unselected.
A row whose value is null is the other way to offer "none": while the model
is null that row shows as checked, as in BbSelect and BbRadioGroup.
Add multiple for checkbox semantics. Give the dropdown an
id and useBbDropdownContext(id)
reads its picks anywhere, as the chips above do; it is read-only, so change a
pick through the group's v-model.
Rows that represent records
Carry application data in meta and target one group's rows with its named slot.
Slot names are the row values in kebab-case: the row whose value is
currentWorkspace is #current-workspace.
Working in Vantera Retail
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdown :items="items" placement="bottom-start" :width="260">
<template #activator="{ props }">
<BbButton
v-bind="props"
append:icon="lucide:chevron-down"
variant="outline"
>
Account
</BbButton>
</template>
<!-- One row by its value: `currentWorkspace` is named in kebab-case. -->
<template #current-workspace:prepend="{ item }">
<BbAvatar v-if="item.meta" size="md">
{{ initials(item.meta) }}
</BbAvatar>
</template>
<!--
Every row of the `workspaces` group, and nothing else: Profile and
Sign out keep their own `prepend:icon`. The record rides in
`item.meta`, so there is no lookup by value.
-->
<template #workspaces:item:prepend="{ item }">
<BbAvatar size="md">{{ initials(item.meta) }}</BbAvatar>
</template>
<!-- On the selected row the check takes this place. -->
<template #workspaces:item:append="{ item }">
<span class="text-xs opacity-60">{{ item.meta.members }}</span>
</template>
<BbDropdownGroup id="workspaces" v-model="workspaceId" />
</BbDropdown>
<p class="text-sm opacity-70" role="status">
Working in {{ current.name }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbAvatar, BbButton, BbDropdown, BbDropdownGroup } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
type Workspace = { id: string; name: string; plan: string; members: number };
const workspaces: Workspace[] = [
{ id: 'vantera', name: 'Vantera Retail', plan: 'Pro', members: 12 },
{ id: 'lumen', name: 'Lumen Outdoor', plan: 'Team', members: 34 },
{ id: 'bellavista', name: 'Osteria Bellavista', plan: 'Free', members: 3 },
];
// The single-select model is the workspace you are in. In an app, watch it
// and make the switch on the server.
const workspaceId = ref('vantera');
const current = computed(
() => workspaces.find((workspace) => workspace.id === workspaceId.value)!
);
const initials = (workspace: Workspace) =>
workspace.name
.split(' ')
.map((word) => word[0])
.join('');
// `meta` is your own data. The dropdown never reads it and never puts it on
// the DOM; it only hands it back to the row's slots as `item.meta`.
const items = computed<BbDropdownItem[]>(() => [
{
value: 'currentWorkspace',
label: current.value.name,
description: `${current.value.plan} plan`,
meta: current.value,
items: [
{
id: 'workspaces',
label: 'Switch workspace',
hideLabel: true,
selectable: true,
items: workspaces.map((workspace) => ({
value: workspace.id,
label: workspace.name,
meta: workspace,
})),
},
],
},
[
{
value: 'profile',
label: 'Profile',
'prepend:icon': 'lucide:user',
href: '#row-slots',
},
{
value: 'sign-out',
label: 'Sign out',
'prepend:icon': 'lucide:log-out',
href: '#row-slots',
},
],
]);
</script>
Use #<groupId>:item:prepend for an avatar or logo. A slot replaces the icon on
that edge; the selected check still wins on the trailing edge.
Search a long group
Mark a long group searchable and it gets its own search field that filters its
rows. To fetch per query instead, pass a function as its items; it runs when
the group is first shown.
ORD-2026-0418 · Refund not received
Assigned to Sofia Marchetti
<template>
<div
class="flex max-w-md items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) px-3 py-2"
>
<div class="min-w-0 flex-1">
<p class="line-clamp-2 text-sm font-medium">
{{ order.reference }} · Refund not received
</p>
<p class="truncate text-xs text-(--bb-text-muted)" role="status">
{{ assigneeNames ? `Assigned to ${assigneeNames}` : 'Unassigned' }}
</p>
</div>
<BbDropdown :items="items" placement="bottom-end" :width="280">
<template #activator="{ props }">
<BbButton
v-bind="props"
append:icon="lucide:chevron-down"
prepend:icon="lucide:user-plus"
size="sm"
variant="outline"
>
Assign
</BbButton>
</template>
<BbDropdownGroup id="assignees" v-model="assignees" />
</BbDropdown>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbDropdown, BbDropdownGroup } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { delay, matches, orders, userById, users } from '~/demo-data';
const order = orders[1]!;
const assignees = ref<number[]>([3]);
// The same provider shape as BbSelect: one context object, `query` in it. It
// runs when the group is first shown, then again (debounced) as the user types.
const findTeammates = ({ query = '' }: { query?: string }) =>
delay(
users.filter((user) => user.active && matches(user.fullName, query)),
400
);
const items: BbDropdownItem[] = [
{
id: 'assignees',
label: 'Assign to',
searchable: true,
searchPlaceholder: 'Find a teammate…',
emptyText: 'No teammate by that name',
selectable: true,
multiple: true,
// Keeps earlier picks resolvable after the query changes.
stash: true,
items: findTeammates,
// The fetcher already matched the query: don't filter its answer again.
filterBy: false,
itemText: 'fullName',
itemValue: 'id',
itemProps: { description: 'jobTitle' },
},
];
const assigneeNames = computed(() =>
assignees.value.map((id) => userById[id]?.fullName).join(', ')
);
</script>
The function receives one object, { query, reason, modelValue, signal }, the
same as a BbSelect provider, so one fetcher serves both. A fetcher that
matches the query itself takes filterBy: false on the group, so its answer is
not filtered again. Set stash: true on a multiple group so
earlier picks survive a new search, and prefill: false to load nothing until
the user types.
groupBy on a group lists its rows under section headings and keeps it one
group: one v-model, one search field. It takes a field path ('sector') or a
function of the row, and reads the raw item on a fetched group. Rows with no
section are listed first, as BbSelect's group-by does. Use separate
group: true sections instead when each answers its own question.
Place and size the panel
Choose placement and width for the surrounding layout.
Sales report
August 2026 · 214 orders
<template>
<div
class="flex max-w-md flex-col gap-3 rounded-(--bb-radius) border border-(--bb-border) p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-0.5">
<p class="font-medium">Sales report</p>
<p class="text-sm text-(--bb-text-muted)">August 2026 · 214 orders</p>
</div>
<!-- `bottom-end` keeps a menu at the right edge inside the page; a
number is a width in pixels. -->
<BbDropdown :items="exports" placement="bottom-end" :width="200">
<template #activator="{ props }">
<BbButton
v-bind="props"
icon="lucide:download"
size="sm"
variant="outline"
>
Export report
</BbButton>
</template>
</BbDropdown>
</div>
<!-- A percentage resolves against the trigger, so the panel is exactly
as wide as the button it belongs to. The rows are a selectable
group, so the current comparison carries the check. -->
<BbDropdown :items="comparisons" placement="bottom-start" width="100%">
<template #activator="{ props }">
<BbButton
v-bind="props"
append:icon="lucide:chevrons-up-down"
block
variant="outline"
>
Compare with: {{ comparisonLabels[comparison] }}
</BbButton>
</template>
<BbDropdownGroup id="compare" v-model="comparison" />
</BbDropdown>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropdown, BbDropdownGroup } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
const comparisonLabels: Record<string, string> = {
'previous-month': 'Previous month',
'last-year': 'Same month last year',
none: 'No comparison',
};
const comparison = ref('previous-month');
const comparisons: BbDropdownItem[] = [
{
id: 'compare',
label: 'Compare with',
hideLabel: true,
selectable: true,
items: Object.entries(comparisonLabels).map(([value, label]) => ({ value, label })),
},
];
const exports: BbDropdownItem[] = [
{ label: 'Export as CSV', 'prepend:icon': 'lucide:file-text', href: '#csv' },
{
label: 'Export as Excel',
'prepend:icon': 'lucide:file-spreadsheet',
href: '#xlsx',
},
{ label: 'Export as PDF', 'prepend:icon': 'lucide:file', href: '#pdf' },
];
</script>
width="100%" matches the trigger's width; a number keeps repeated menus aligned.
Add compact for denser rows beside a compact select.
For a context menu, pair trigger="contextMenu" with placement="cursor".
On a phone
On small screens the menu opens as a bottom sheet, with the same rows, slots and
selection. Pass :adaptive="false"
only when the menu must stay a flyout; off-canvas-props
configures the sheet.
Coming from v2
arrowPadding is removed: dropdowns draw no arrow. The phone sheet is new and on
by default. An item's data-* / aria-* keys no longer reach its row: move them
to pt:item (Style items).
Style items
destructive is the only styled item variant. Register additional names through
dropdownItemVariants, then style .bb-dropdown__item--variant-<name> yourself. Prefer
pt:panel, pt:item and
pt:input (every search field in the menu) for instance-level changes.
An item takes only the fields the menu reads. An attribute for its row, such as
a data-* test hook or an aria-*, goes through pt:item, per row with the
function form: :pt:item="({ item }) => ({ attrs: { 'data-test': item.value } })".
An aria-label or aria-describedby set there names or describes the row.
Two custom properties on the menu size the desktop rows, with BbButton's
names: --fs the label (default var(--bb-fs)) and --icon-size the
leading and trailing icons (default 1rem). Submenus follow them; the phone
sheet keeps its touch sizes.
<BbDropdown class="[--fs:13px] [--icon-size:14px]" :items="items" />
Coming from v2
The floating wrapper is now a <div>, not a <span>. Class hooks remain; only
element-specific selectors stop matching.