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
Also see: Options and items.
Its item model comes from BbDropdown; variants
and sizes come from BbButton.
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.
Put the frequent action first
The component is the main button: its label is the default slot, and
@click, href / to, type / form, loading, disabled and the icons
work exactly as on a BbButton.
items is only the menu
that opens from the chevron.
Unsaved changes
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdownButton
:items="items"
prepend:icon="lucide:check"
@click="status = 'Saved'"
>
Save
</BbDropdownButton>
<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');
// The component is the main button: its label and @click above. `items` is
// only the menu behind the chevron, BbDropdown's item model, so href,
// descriptions and groups all work there.
const items: BbDropdownItem[] = [
{
value: 'draft',
label: 'Save as draft',
'prepend:icon': 'lucide:pencil',
onClick: () => (status.value = 'Saved as draft'),
},
{
value: 'template',
label: 'Save as template',
'prepend:icon': 'lucide:copy',
onClick: () => (status.value = 'Saved as template'),
},
];
</script>
- Every item is a menu row, with
BbDropdown's item model, so groups, submenus, links and descriptions all work there. - A list that comes down to nothing can stay on the component: with no
items (and no
#dropdownslot) the chevron goes and it renders exactly like aBbButton. - In a form, write
type="submit"(andform="id"from outside it) on the component: the main button submits theBbFormand spins while its save runs, like a submitBbButton. Menu rows stay plain actions. - Navigation goes on
href/to, the component's or a row's, never in a click handler.
Coming from v2items[0] → the component
Up to beta.57, items[0] became the main button. It is now the first menu row.
Move it onto the component: its label becomes the default slot, its
onClick the component's @click, its href / to / type / form /
disabled the component's props. npx bitboss-ui check flags a split button
with no label.
- <BbDropdownButton :items="[{ value: 'save', label: 'Save', onClick: save }, ...alternates]" />
+ <BbDropdownButton :items="alternates" @click="save">Save</BbDropdownButton>
Keep both halves coherent
Set variant, size, block and icons on the component: both halves take them, so they never disagree.
<template>
<div class="flex flex-col gap-8 sm:flex-row sm:items-start sm:gap-14">
<!-- One variant, one size and one set of icons for both halves. -->
<div class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-6 gap-y-3">
<template v-for="row in variants" :key="row.variant">
<span class="font-mono text-xs text-(--bb-text-muted)">
{{ row.variant }}
</span>
<div>
<BbDropdownButton
:items="row.items"
:prepend:icon="row.icon"
:variant="row.variant"
>
{{ row.label }}
</BbDropdownButton>
</div>
</template>
</div>
<div class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-6 gap-y-3">
<template v-for="size in sizes" :key="size">
<span class="font-mono text-xs text-(--bb-text-muted)">{{ size }}</span>
<div>
<BbDropdownButton :items="exports" :size="size" variant="outline">
Export
</BbDropdownButton>
</div>
</template>
<!-- `toggle:icon` swaps only the toggle glyph; `block` widens the main half. -->
<span class="font-mono text-xs text-(--bb-text-muted)">block</span>
<div class="max-w-56">
<BbDropdownButton
block
:items="exports"
toggle:icon="lucide:ellipsis"
variant="outline"
>
Export
</BbDropdownButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { BbDropdownButton } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
const sizes = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const;
// The two halves take their look from the component, so they can never
// disagree. `items` is only the menu.
const exports: BbDropdownItem[] = [
{ value: 'csv', label: 'Export as CSV', onClick: () => {} },
{ value: 'json', label: 'Export as JSON', onClick: () => {} },
];
const discards: BbDropdownItem[] = [
{ value: 'discard-close', label: 'Discard and close', onClick: () => {} },
];
const variants = [
{ variant: 'primary', items: exports, icon: 'lucide:download', label: 'Export' },
{ variant: 'secondary', items: exports, icon: 'lucide:download', label: 'Export' },
{ variant: 'outline', items: exports, icon: 'lucide:download', label: 'Export' },
{ variant: 'ghost', items: exports, icon: 'lucide:download', label: 'Export' },
{ variant: 'destructive', items: discards, icon: 'lucide:trash-2', label: 'Discard' },
{ variant: 'link', items: exports, icon: 'lucide:download', label: 'Export' },
] as const;
</script>
Every BbButton variant and size applies, destructive and link included.
toggle:icon replaces only the toggle chevron.
The #icon, #prepend:icon and #append:icon slots belong to the main button,
as on BbButton: they swap its glyph with its
states (loading, activeLink, …), inside its icon part.
To start a style from scratch, variant="none" gives both halves a blank base
and your classes draw the rest; you own the hover, the focus ring and the
disabled look. Reach each half with pt:main
and pt:toggle. (On Tailwind v4 your classes win over the button's own rules
only with cssLayers set.)
Draft · last edited 10:42
<template>
<div class="flex flex-wrap items-center gap-4">
<!--
`pt:main` and `pt:toggle` reach the two halves. Each one rounds only its
outer side, so the seam between them stays square.
-->
<BbDropdownButton
:items="items"
prepend:icon="lucide:send"
:pt:main="`${half} rounded-l-full pl-4`"
:pt:toggle="`${half} rounded-r-full border-l border-(--bb-panel)/30 pr-2.5`"
variant="none"
@click="status = 'Published'"
>
Publish
</BbDropdownButton>
<p class="text-sm text-(--bb-text-muted)" 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('Draft · last edited 10:42');
// With variant="none" you own the colours, the hover and the focus ring.
const half =
'bg-(--bb-text) text-(--bb-panel) hover:bg-(--bb-text)/85 focus-visible:relative focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--bb-text)';
const items: BbDropdownItem[] = [
{
value: 'schedule',
label: 'Schedule for later',
onClick: () => (status.value = 'Scheduled for tomorrow, 09:00'),
},
{
value: 'draft',
label: 'Save as draft',
onClick: () => (status.value = 'Draft saved'),
},
];
</script>
When the look recurs, register it as a variant:
buttonVariants names are valid here too. When the trigger is not a button at
all, build it from BbBaseButton and
BbDropdown.
Coming from v2theme → variant
theme is removed; use variant. arrowPadding is also removed because the
menu has no arrow.
Return promises from actions
When the component's @click returns a promise, the main button shows loading
and blocks re-entry; a menu row's async onClick does the same to the toggle.
Draft
<template>
<div class="flex flex-wrap items-center gap-3">
<BbDropdownButton :items="items" prepend:icon="lucide:rocket" @click="publish">
Publish
</BbDropdownButton>
<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 @click spins the main
// button; the promise returned by a menu item's onClick spins the toggle.
async function publish() {
status.value = 'Publishing…';
await delay(null, 1200);
status.value = 'Published';
}
const items: BbDropdownItem[] = [
{
value: 'preview',
label: 'Rebuild preview',
'prepend:icon': 'lucide:eye',
onClick: async () => {
status.value = 'Rebuilding preview…';
await delay(null, 1200);
status.value = 'Preview rebuilt';
},
},
{
value: 'revert',
label: '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>
<!-- The confirm renders in the app's one <BbHost />, mounted at the root. -->
<BbDropdownButton
:items="items"
placement="bottom-end"
size="sm"
variant="outline"
@click="redeploy"
>
Redeploy
</BbDropdownButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { 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();
async function redeploy() {
status.value = 'Rebuilding…';
await delay(null, 1000);
status.value = 'dpl_9m4a · deployed just now';
}
// The dangerous alternate stays behind the chevron, gated by useConfirm.
const items: BbDropdownItem[] = [
{
value: 'logs',
label: 'View build logs',
'prepend:icon': 'lucide:scroll-text',
href: '#logs',
},
{
value: 'delete',
label: '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>
The confirm renders in the app's one <BbHost />, mounted at the root; a page
with a split button mounts nothing.
Let the menu configure the action
A selectable group in the menu can parameterise the main action. Make the label follow the selection, so it 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"
@click="status = `Merged into main (${method})`"
@item:selected="onMethodPicked"
>
{{ labels[method] }}
<template #dropdown>
<BbDropdownGroup id="method" v-model="method" />
</template>
</BbDropdownButton>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { 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');
// The menu's `item:selected` reaches the split button too: use it for the
// moment of the change, and the group's v-model for the state.
const onMethodPicked = (value: MergeMethod) => {
status.value = `Method set to ${value}; nothing merged yet`;
};
// The menu picks the method; the label follows it, so the control always
// says what pressing it will do.
const items: BbDropdownItem[] = [
{
id: 'method',
label: 'Merge method',
selectable: true,
items: [
{ value: 'merge', label: 'Create a merge commit' },
{ value: 'squash', label: 'Squash and merge' },
{ value: 'rebase', label: 'Rebase and merge' },
],
},
];
</script>
Bind the group with a <BbDropdownGroup> companion in the #dropdown slot: the
default slot is the label. The menu's item:selected and item:unselected reach the split button, so
listen there for the moment of a change.
Use the inherited menu surface
Menu slots, placement props, compact, adaptive and
off-canvas-props forward to the inner dropdown: every slot but the label
(default), #dropdown and activator.
<template>
<!--
The wrapper keeps the control as wide as its content, so the forwarded
`placement` lines the menu up with the split button, not the whole row.
-->
<div class="flex">
<BbDropdownButton
compact
:items="items"
:off-canvas-props="{ title: 'New document' }"
placement="bottom-start"
href="#new"
prepend:icon="lucide:file-plus"
pt:footer="bg-(--bb-muted)"
size="sm"
variant="outline"
:width="240"
>
New document
<!-- Every slot except the label, `#dropdown` and `activator` reaches the
inner dropdown. -->
<template #item:prepend>
<BbIcon icon="lucide:file-text" size="sm" />
</template>
<!-- The band around this slot already has padding; `pt:footer` tints it
on the flyout and on the phone sheet alike. -->
<template #footer>
<BbButton block href="#drafts" size="sm" variant="ghost">
Browse all drafts
</BbButton>
</template>
</BbDropdownButton>
</div>
</template>
<script setup lang="ts">
import { BbButton, BbDropdownButton, BbIcon } from 'bitboss-ui';
import type { BbDropdownItem } from 'bitboss-ui';
// `item:prepend` decorates the menu rows only; the main button's icon is
// `prepend:icon` above. `compact` matches the menu rows to the small button;
// `off-canvas-props` titles the sheet the menu becomes on a phone.
const items: BbDropdownItem[] = [
{ value: 'template', label: 'From a template', href: '#template' },
{ value: 'import', label: 'Import a file', href: '#import' },
];
</script>
item:prepend decorates the menu rows only; the main button's icon is its own
prepend:icon. pt:header and pt:footer style the bands around those two
slots, on the flyout and the phone sheet alike. The menu aligns to the whole split control;
trigger, anchor and boundary are not exposed, so use BbDropdown directly
when one of them is required.