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 behavior.
Use something else when
BbButton: there is one actionBbDropdownButton: one action dominates and the rest are close alternativesBbSelect: the user chooses a form value
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
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. `key` identifies the entry, `text` is what it says, and the
// item does something (onClick) or goes somewhere (href) — never both.
const items: BbDropdownItem[] = [
{
key: 'invoice',
text: 'Download invoice',
onClick: () => (last.value = `Invoice for ${order.reference} downloaded`),
},
{ key: 'tracking', text: 'Track shipment', href: '#tracking' },
{
key: 'note',
text: 'Add internal note',
onClick: () => (last.value = `Note added to ${order.reference}`),
},
];
</script>
Use a real button and name icon-only triggers. The supplied props wire expanded state, focus return and keyboard navigation.
Choose the right item type
Use onClick for commands and href or to for navigation, so links keep
native browser behavior.
Q2 revenue review.pdf
2.4 MB · shared with the Finance team
<template>
<div class="flex max-w-md items-center gap-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{{ file.name }}</p>
<p class="text-xs opacity-70" role="status">
{{ formatFileSize(file.size) }} · {{ status }}
</p>
</div>
<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>
<!--
This page mounts its single <BbConfirm /> here, because the docs site
has no host of its own. Your app mounts one at the root and a page
like this one mounts nothing.
-->
<BbConfirm />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbConfirm, 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.
{
key: 'rename',
text: '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.
{
key: 'preview',
text: 'Open preview',
'prepend:icon': 'lucide:external-link',
href: '#preview',
},
// A nested array is its own section, drawn with a divider above it.
[
{
key: 'delete',
text: '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>
Items need a stable key; text is the label and description adds a second
line. Gate destructive actions with useConfirm.
Coming from v2
text is the v3 item label, and destructive rows use
variant: 'destructive'. In fetched groups, map extra row fields through
itemProps; raw href, icons and disabled fields are otherwise ignored.
Use the directive for simple menus
When the trigger already exists and the menu is a flat items array, use the globally registered directive.
Signed in
<template>
<div class="flex flex-wrap items-center gap-3">
<!--
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"
append:icon="lucide:chevron-down"
variant="outline"
>
{{ owner.fullName }}
</BbButton>
<p class="text-sm opacity-70" role="status">{{ last }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton } from 'bitboss-ui';
import type { BbDropdownDirectiveValue } from 'bitboss-ui';
import { userById } from '~/demo-data';
const owner = userById[7]!;
const last = ref('Signed in');
// 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 = [
{ key: 'profile', text: 'View profile', href: '#profile' },
{
key: 'settings',
text: 'Account settings',
'prepend:icon': 'lucide:settings',
href: '#settings',
},
{
key: 'signout',
text: 'Sign out',
'prepend:icon': 'lucide:log-out',
variant: 'destructive',
onClick: () => (last.value = 'Signed out'),
},
];
</script>
Use the component for slots, sections or a <BbDropdownGroup> companion.
Track async actions
Return a promise from an item's onClick; the activator slot exposes loading
until it settles and ignores repeat clicks.
Last synced at 09:24
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdown :items="items" :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"
variant="outline"
>
Catalogue
</BbButton>
</template>
</BbDropdown>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</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('Last synced at 09:24');
const items: BbDropdownItem[] = [
{
key: 'sync',
text: 'Sync catalogue',
'prepend:icon': 'lucide:refresh-cw',
onClick: async () => {
status.value = 'Syncing…';
await delay(null, 1200);
status.value = `${products.length} products synced`;
},
},
{
key: 'history',
text: '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.
Unassigned
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdown :items="items" placement="bottom-start" :width="230">
<template #activator="{ props }">
<BbButton
v-bind="props"
append:icon="lucide:chevron-down"
variant="outline"
>
Move to team
</BbButton>
</template>
</BbDropdown>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropdown } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { usersByTeam } from '~/demo-data';
const status = ref('Unassigned');
// 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[] = usersByTeam.slice(0, 3).map((group) => ({
key: group.label,
text: group.label,
'prepend:icon': 'lucide:users',
items: group.users.slice(0, 3).map((user) => ({
key: `u-${user.id}`,
text: user.fullName,
description: user.jobTitle,
onClick: () => (status.value = `Assigned to ${user.fullName}`),
})),
}));
</script>
Desktop uses flyouts; the mobile sheet drills down one level at a time.
Build selectable groups
Mark a group selectable, then bind its state with <BbDropdownGroup> using the
same id as the group's key.
Sorted by recent · pending
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdown :items="items" :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 key as its
`id` and a v-model. The options themselves stay in `items`.
-->
<BbDropdownGroup id="sort" v-model="sort" />
<BbDropdownGroup id="status" v-model="statuses" />
</BbDropdown>
<p class="text-sm opacity-70" role="status">
Sorted by {{ sort }} · {{ statuses.join(', ') || 'every status' }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDropdown, BbDropdownGroup } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { orderStatusLabels, orderStatuses } from '~/demo-data';
const sort = ref('recent');
const statuses = ref<string[]>(['pending']);
// `selectable` alone is a single choice with radio semantics; adding
// `multiple` makes it checkboxes and keeps the menu open while you toggle.
const items: BbDropdownItem[] = [
{
key: 'sort',
label: 'Sort by',
selectable: true,
items: [
{ key: 'recent', text: 'Most recent' },
{ key: 'total', text: 'Total (high to low)' },
{ key: 'customer', text: 'Customer name' },
],
},
{
key: 'status',
label: 'Status',
selectable: true,
multiple: true,
items: orderStatuses.slice(0, 4).map((status) => ({
key: status,
text: orderStatusLabels[status],
})),
},
];
</script>
Add multiple for checkbox semantics. Provider-backed groups can share the same
fetch contract as BbSelect; keep enforce-coherence for lists that can change
under an existing choice.
Rows that represent records
Carry application data in meta and target one group's rows with its named slot.
Working in Acme Studio
<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 key: the row that opens the switcher. -->
<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 key.
-->
<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: 'acme', name: 'Acme Studio', plan: 'Pro', members: 12 },
{ id: 'northwind', name: 'Northwind Labs', plan: 'Team', members: 34 },
{ id: 'globex', name: 'Globex Retail', 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('acme');
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[]>(() => [
{
key: 'current-workspace',
text: current.value.name,
description: `${current.value.plan} plan`,
meta: current.value,
items: [
{
key: 'workspaces',
label: 'Switch workspace',
hideLabel: true,
selectable: true,
items: workspaces.map((workspace) => ({
key: workspace.id,
text: workspace.name,
meta: workspace,
})),
},
],
},
[
{
key: 'profile',
text: 'Profile',
'prepend:icon': 'lucide:user',
href: '#row-slots',
},
{
key: 'sign-out',
text: 'Sign out',
'prepend:icon': 'lucide:log-out',
href: '#row-slots',
},
],
]);
</script>
Use #<groupKey>:item:prepend for an avatar or logo. A slot replaces the icon on
that edge; the selected check still wins on the trailing edge.
Place and size the panel
Choose placement and width for the surrounding layout.
<template>
<div class="flex flex-wrap items-start gap-4">
<!-- A percentage width resolves against the trigger, so the panel is
exactly as wide as the button it belongs to. -->
<BbDropdown :items="plans" placement="bottom-start" width="100%">
<template #activator="{ props }">
<BbButton
v-bind="props"
append:icon="lucide:chevrons-up-down"
block
class="w-64"
variant="outline"
>
Choose a plan
</BbButton>
</template>
</BbDropdown>
<!-- A number is pixels, and `bottom-end` keeps a right-hand menu inside
the page instead of pushing past it. -->
<BbDropdown :items="exports" placement="bottom-end" :width="200">
<template #activator="{ props }">
<BbButton v-bind="props" aria-label="Export" icon="lucide:download" />
</template>
</BbDropdown>
</div>
</template>
<script setup lang="ts">
import { BbButton, BbDropdown } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
const plans: BbDropdownItem[] = [
{
key: 'starter',
text: 'Starter',
description: 'One workspace, two seats',
href: '#starter',
},
{
key: 'team',
text: 'Team',
description: 'Shared catalogue and roles',
href: '#team',
},
{
key: 'scale',
text: 'Scale',
description: 'Custom contract',
href: '#scale',
},
];
const exports: BbDropdownItem[] = [
{ key: 'csv', text: 'Export as CSV', href: '#csv' },
{ key: 'json', text: 'Export as JSON', href: '#json' },
];
</script>
width="100%" follows the anchor; numeric widths align repeated menus.
For a context menu, pair trigger="contextMenu" with placement="cursor".
Coming from v2
arrowPadding is removed; dropdowns do not draw arrows.
Check the mobile sheet
On small screens the menu becomes a bottom sheet by default. Verify submenus and destructive actions on that surface.
Use :adaptive="false" only when the menu must remain a flyout.
off-canvas-props configures the sheet.
Coming from v2
Adaptive mode is new and enabled by default in v3. offcanvasProps becomes
offCanvasProps.
Style items
destructive is the only styled item variant. Register additional names through
dropdownItemVariants, then style their generated modifier classes. Prefer
pt:panel and pt:item for instance-level changes.
Coming from v2
The floating wrapper is now a <div>, not a <span>. Class hooks remain; only
element-specific selectors stop matching.