Use it for
Use BbDropdownButton when one action is frequent and the menu contains close
alternatives: Save / Save as draft, Export / Export as CSV, Merge / Squash.
Use something else when
BbButton: there is one actionBbDropdown: no action clearly dominates
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Its item model comes from BbDropdown; variants
and sizes come from BbButton.
Put the frequent action first
items[0] becomes the main button; every later item opens from the toggle.
Unsaved changes
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdownButton :items="items" prepend:icon="lucide:check" />
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbDropdownButton } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
const status = ref('Unsaved changes');
// items[0] is promoted into the main button; every later entry opens from the
// chevron. It is BbDropdown's item model, so href, descriptions and sections
// all work in the menu half.
const items: BbDropdownItem[] = [
{ key: 'save', text: 'Save', onClick: () => (status.value = 'Saved') },
{
key: 'draft',
text: 'Save as draft',
'prepend:icon': 'lucide:pencil',
onClick: () => (status.value = 'Saved as draft'),
},
{
key: 'template',
text: 'Save as template',
'prepend:icon': 'lucide:copy',
onClick: () => (status.value = 'Saved as template'),
},
];
</script>
Keep the first entry a plain leaf item. A leading group or nested array promotes only its first leaf and silently drops the rest of that entry.
Keep both halves coherent
Set variant, size, block and icons on the component, not items[0].
<template>
<div class="flex flex-col items-start gap-4">
<div class="flex flex-wrap items-center gap-2">
<BbDropdownButton :items="items" prepend:icon="lucide:download" />
<BbDropdownButton
:items="items"
prepend:icon="lucide:download"
variant="secondary"
/>
<BbDropdownButton
:items="items"
prepend:icon="lucide:download"
variant="outline"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<BbDropdownButton :items="items" size="xs" variant="outline" />
<BbDropdownButton :items="items" size="sm" variant="outline" />
<BbDropdownButton :items="items" size="lg" variant="outline" />
</div>
<!-- `right:icon` swaps only the toggle glyph. -->
<div class="w-64">
<BbDropdownButton
block
:items="items"
right:icon="lucide:ellipsis"
variant="outline"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { BbDropdownButton } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
// The primary item's own variant and icons are ignored on purpose: the two
// halves take their look from the component, so they can never disagree.
const items: BbDropdownItem[] = [
{ key: 'export', text: 'Export', href: '#export' },
{ key: 'csv', text: 'Export as CSV', href: '#csv' },
{ key: 'json', text: 'Export as JSON', href: '#json' },
];
</script>
right:icon replaces only the toggle chevron.
Coming from v2theme → variant
theme is removed; use variant. arrowPadding is also removed because the
menu has no arrow.
Return promises from actions
When an action returns a promise, its half shows loading and blocks re-entry.
Draft
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdownButton :items="items" prepend:icon="lucide:rocket" />
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbDropdownButton } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { delay } from '~/demo-data';
const status = ref('Draft');
/**
* Nothing sets a loading flag. The promise returned by the primary onClick
* spins the main button; the promise returned by a menu item's onClick spins
* the toggle instead.
*/
const items: BbDropdownItem[] = [
{
key: 'publish',
text: 'Publish',
onClick: async () => {
status.value = 'Publishing…';
await delay(null, 1200);
status.value = 'Published';
},
},
{
key: 'preview',
text: 'Rebuild preview',
'prepend:icon': 'lucide:eye',
onClick: async () => {
status.value = 'Rebuilding preview…';
await delay(null, 1200);
status.value = 'Preview rebuilt';
},
},
{
key: 'revert',
text: 'Revert to draft',
onClick: () => (status.value = 'Draft'),
},
];
</script>
Use loading only for external work. disable-auto-loading opts the main action
out; menu actions still drive the toggle.
Coming from v2autoLoading → disableAutoLoading
The prop was renamed with inverted polarity. disabledWhileLoading is removed;
busy halves are always disabled.
Gate dangerous alternatives
Keep irreversible work behind the toggle, mark it destructive and await
confirmation before acting.
vantera-web · production
dpl_7k2f · deployed 08 Jul 2026
<template>
<div class="flex max-w-md flex-wrap items-center gap-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">vantera-web · production</p>
<p class="text-xs opacity-70" role="status">{{ status }}</p>
</div>
<BbDropdownButton
:items="items"
placement="bottom-end"
size="sm"
variant="outline"
/>
<!--
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 { BbConfirm, BbDropdownButton, useConfirm } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
import { delay } from '~/demo-data';
const status = ref('dpl_7k2f · deployed 08 Jul 2026');
const { confirm } = useConfirm();
const items: BbDropdownItem[] = [
{
key: 'redeploy',
text: 'Redeploy',
onClick: async () => {
status.value = 'Rebuilding…';
await delay(null, 1000);
status.value = 'dpl_9m4a · deployed just now';
},
},
{ key: 'logs', text: 'View build logs', href: '#logs' },
{
key: 'delete',
text: 'Delete deployment',
'prepend:icon': 'lucide:trash-2',
variant: 'destructive',
onClick: async () => {
const ok = await confirm({
title: 'Delete this deployment?',
text: 'The deployment and its build logs go for good.',
variant: 'destructive',
yes: { text: 'Delete deployment', 'prepend:icon': 'lucide:trash-2' },
no: 'Keep it',
});
if (ok) status.value = 'Deployment deleted';
},
},
];
</script>
Mount one <BbConfirm> host at application level, not beside every split button.
Let the menu configure the action
A selectable group can change the primary action; recompute items so the main
label always states what pressing it will do.
2 approvals · all checks passed
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdownButton :items="items" prepend:icon="lucide:git-merge">
<BbDropdownGroup id="method" v-model="method" />
</BbDropdownButton>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbDropdownButton, BbDropdownGroup } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
type MergeMethod = 'merge' | 'squash' | 'rebase';
const labels: Record<MergeMethod, string> = {
merge: 'Merge pull request',
squash: 'Squash and merge',
rebase: 'Rebase and merge',
};
const method = ref<MergeMethod>('squash');
const status = ref('2 approvals · all checks passed');
// `items` is a computed, so the selection re-labels items[0] — the main button
// itself — and the control always says what pressing it will do.
const items = computed<BbDropdownItem[]>(() => [
{
key: 'merge-pr',
text: labels[method.value],
onClick: () => (status.value = `Merged into main (${method.value})`),
},
{
key: 'method',
label: 'Merge method',
selectable: true,
items: [
{ key: 'merge', text: 'Create a merge commit' },
{ key: 'squash', text: 'Squash and merge' },
{ key: 'rebase', text: 'Rebase and merge' },
],
},
]);
</script>
Bind the group with <BbDropdownGroup> and give the promoted item a unique key.
Use the inherited menu surface
Menu slots and placement props forward to the inner dropdown.
<template>
<BbDropdownButton
:items="items"
placement="bottom-end"
prepend:icon="lucide:file-plus"
variant="outline"
:width="240"
>
<!-- Every slot except `activator` reaches the inner dropdown. -->
<template #item:prepend>
<BbIcon icon="lucide:file-text" size="sm" />
</template>
<template #footer>
<div class="p-2">
<BbButton block href="#drafts" size="sm" variant="ghost">
Browse all drafts
</BbButton>
</div>
</template>
</BbDropdownButton>
</template>
<script setup lang="ts">
import { BbButton, BbDropdownButton, BbIcon } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
// The generic edge slot decorates menu rows only: items[0] is the main button,
// so it never sees `item:prepend` — its icon comes from `prepend:icon` above.
const items: BbDropdownItem[] = [
{ key: 'new-doc', text: 'New document', href: '#new' },
{ key: 'template', text: 'From a template', href: '#template' },
{ key: 'import', text: 'Import a file', href: '#import' },
];
</script>
The menu aligns to the whole split control. trigger, adaptive,
offCanvasProps, anchor and boundary are not exposed; use BbDropdown
directly when one of them is required.