Use it for
Use BbButton for actions the user performs — save, delete, submit — and for
destinations that need button styling. Actions use @click or type="submit";
navigation uses href or to.
Use something else when
BbDropdownButton— one dominant action has close alternatives: Save / Save as draftBbDropdown— no action dominatesBbBaseButton— the surface is clickable with no button chrome at all: a table row, a card
Also see: Design language, Icons.
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.
Only one button submits the form
BbButton is the library's button, and its label is slot content. type
defaults to button, so inside a form the only button that submits is the one
you mark type="submit".
<template>
<form
class="flex max-w-md flex-col gap-4 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
@submit.prevent="status = 'Published.'"
>
<BbTextInput
id="post-title"
v-model="title"
label="Post title"
name="title"
/>
<!-- Only the button marked type="submit" submits: the other two are
type="button" by default and never trigger the form. -->
<div
class="flex flex-wrap items-center justify-end gap-2 border-t border-[color:var(--bb-border)] pt-4"
>
<span
class="basis-full text-xs text-[color:var(--bb-text-muted)] sm:mr-auto sm:basis-auto"
>
{{ status }}
</span>
<div class="ml-auto flex flex-wrap justify-end gap-2">
<BbButton variant="ghost" @click="title = saved">Cancel</BbButton>
<BbButton variant="outline" @click="status = 'Draft saved.'">
Save draft
</BbButton>
<BbButton type="submit">Publish</BbButton>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTextInput } from 'bitboss-ui';
const saved = 'Autumn catalogue: what is new';
const title = ref(saved);
const status = ref('Not published yet');
</script>
The variant ladder
Variants are a hierarchy, not a palette: one primary per region of the page,
then secondary, outline, ghost.
Keep destructive for what cannot be undone. link recolors the button without
changing its box, so it is the wrong choice for an inline text link.
| variant | Default | Disabled | Loading |
|---|---|---|---|
| primary | |||
| secondary | |||
| outline | |||
| ghost | |||
| destructive | |||
| link |
<template>
<div class="w-full max-w-full min-w-0 overflow-x-auto">
<table class="text-sm whitespace-nowrap">
<thead>
<tr class="text-left text-xs text-[color:var(--bb-text-muted)]">
<th class="pr-2 pb-3 font-medium sm:pr-6">variant</th>
<th class="pr-2 pb-3 font-medium sm:pr-4">Default</th>
<th class="pr-3 pb-3 font-medium sm:pr-4">Disabled</th>
<!-- A phone has room for three columns; the loading state is also live in Async actions. -->
<th class="hidden pb-3 font-medium sm:table-cell">Loading</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.variant">
<th class="py-1.5 pr-2 text-left font-mono text-xs font-normal sm:pr-6">
{{ row.variant }}
</th>
<td class="py-1.5 pr-2 sm:pr-4">
<BbButton :variant="row.variant">{{ row.label }}</BbButton>
</td>
<td class="py-1.5 pr-3 sm:pr-4">
<BbButton disabled :variant="row.variant">{{ row.label }}</BbButton>
</td>
<td class="hidden py-1.5 sm:table-cell">
<BbButton loading :variant="row.variant">{{ row.label }}</BbButton>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { BbButton } from 'bitboss-ui';
/** Top to bottom is the order of weight: one primary per region, then down. */
const rows = [
{ variant: 'primary', label: 'Save' },
{ variant: 'secondary', label: 'Duplicate' },
{ variant: 'outline', label: 'Export' },
{ variant: 'ghost', label: 'Cancel' },
{ variant: 'destructive', label: 'Delete' },
{ variant: 'link', label: 'Learn more' },
] as const;
</script>
Sizes and width
Six sizes, xs to 2xl. md (28px) is the default and has the
height of a compact field, so leave size off. Use sm or xs on dense surfaces
and lg or larger for a rare hero action.
block stretches the button to its container.
size also takes a map by breakpoint, such as { default: 'sm', lg: 'lg' }.
| xs | |||
|---|---|---|---|
| sm | |||
| md | |||
| lg | |||
| xl | |||
| 2xl |
<template>
<div class="flex flex-col gap-6">
<div class="w-full max-w-full min-w-0 overflow-x-auto">
<table class="text-sm whitespace-nowrap">
<tbody>
<tr v-for="size in sizes" :key="size">
<th
class="py-1.5 pr-4 text-left font-mono sm:pr-6 text-xs font-normal text-[color:var(--bb-text-muted)]"
>
{{ size }}
</th>
<!-- On a phone the two outline columns carry the scale on their own. -->
<td class="hidden py-1.5 pr-3 sm:table-cell">
<BbButton :size="size">Save</BbButton>
</td>
<td class="py-1.5 pr-3">
<BbButton
prepend:icon="lucide:plus"
:size="size"
variant="outline"
>
New invoice
</BbButton>
</td>
<td class="py-1.5">
<BbButton icon="lucide:settings" :size="size" variant="outline">
Settings
</BbButton>
</td>
</tr>
</tbody>
</table>
</div>
<div class="max-w-xs">
<BbButton block>Continue to payment</BbButton>
</div>
</div>
</template>
<script setup lang="ts">
import { BbButton } from 'bitboss-ui';
const sizes = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const;
</script>
Name icon-only buttons
prepend:icon and append:icon put an icon beside the label. For an icon-only
button use icon and keep writing the label in the slot — it is hidden visually
and still announced. Use aria-label only when that accessible name must differ
from the slot text. An icon-only button with neither is an error for
bitboss-ui check and the ESLint rule bitboss-ui/require-accessible-name.
<template>
<div class="flex max-w-md flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<BbButton prepend:icon="lucide:plus">New invoice</BbButton>
<BbButton append:icon="lucide:download" variant="outline"
>Export</BbButton
>
</div>
<!-- Icon-only: the slot text is the accessible name (rendered sr-only),
and the tooltip shows the same words to sighted mouse users. -->
<div
aria-label="Invoice actions"
class="flex w-fit items-center gap-1 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-1"
role="group"
>
<BbButton
v-for="action in actions"
:key="action.label"
v-bb-tooltip="action.label"
:icon="action.icon"
size="sm"
variant="ghost"
>
{{ action.label }}
</BbButton>
<span class="mx-1 h-5 w-px bg-[color:var(--bb-border)]" />
<BbButton
v-bb-tooltip="'Delete invoice'"
icon="lucide:trash-2"
size="sm"
variant="ghost"
>
Delete invoice
</BbButton>
</div>
</div>
</template>
<script setup lang="ts">
import { BbButton, vBbTooltip } from 'bitboss-ui';
const actions = [
{ label: 'Print', icon: 'lucide:printer' },
{ label: 'Duplicate', icon: 'lucide:copy' },
{ label: 'Send by email', icon: 'lucide:mail' },
{ label: 'Copy link', icon: 'lucide:link' },
];
</script>
To change the glyph with the button's state, use the slot named after the
prop: #icon, #prepend:icon or #append:icon. It replaces the prop and
renders inside the icon part, so pt:icon, --icon-size and the loading
spinner work as they do on the prop's glyph. Its scope is the button's states,
{ pressed, loading, disabled, activeLink, exactActiveLink }: the toggle
below swaps its bell when pressed, and a navigation link can show a solid icon
on the current page. A BbIcon in the slot takes the part's size.
Async actions
A click handler that returns a promise puts the button into its loading state
until the promise settles and blocks repeat clicks. Fire-and-forget work is not
trackable; return the promise or bind loading to the state that owns it.
Delete ORD-2026-0421?
The order and its invoice are removed for everyone. This cannot be undone.
<template>
<div
class="flex min-h-40 max-w-md flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4 shadow-[var(--bb-shadow-sm)]"
>
<template v-if="!deleted">
<p class="font-medium">Delete {{ order.reference }}?</p>
<p class="text-sm text-[color:var(--bb-text-muted)]">
The order and its invoice are removed for everyone. This cannot be
undone.
</p>
<div class="flex justify-end gap-2 pt-2">
<BbButton variant="ghost">Cancel</BbButton>
<!-- The handler returns a promise: the button shows its spinner and
ignores clicks until it settles. No loading ref needed. -->
<BbButton
prepend:icon="lucide:trash-2"
variant="destructive"
@click="onDelete"
>
Delete order
</BbButton>
</div>
</template>
<!-- The card keeps its height, so the page below does not jump. -->
<div v-else class="my-auto flex items-center justify-between gap-3 text-sm">
<span>{{ order.reference }} was deleted.</span>
<BbButton size="sm" variant="outline" @click="deleted = false">
Back to orders
</BbButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton } from 'bitboss-ui';
import { delay, orders } from '~/demo-data';
const order = orders[2]!;
const deleted = ref(false);
const onDelete = async () => {
await delay(null, 1500);
deleted.value = true;
};
</script>
Coming from v2
In v2 this was opt-in through auto-loading. Delete that prop in v3. Use
disable-auto-loading only when you intentionally need the old behaviour.
A type="submit" button inside a BbForm
spins by itself while a @submit that returned a promise runs. Only the button
that submitted spins, and a submit button outside the form that names it with
form="<id>" does too. A submit from code spins none.
disable-auto-loading turns off every spin the button starts on its own, the
form's included; only loading shows the spinner then. Reach for it on a button
whose click awaits a whole dialog
(await show()): spinning until the
dialog closes would read as busy.
Buttons that navigate
Pass href or to instead of navigating from a click handler. The result is a
real link with middle-click, open-in-new-tab and copy-address behaviour.
<template>
<div
class="flex max-w-md flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div class="flex flex-col gap-0.5">
<p class="font-medium">{{ order.reference }} has shipped</p>
<p class="text-sm text-[color:var(--bb-text-muted)]">
DHL Express · tracking 4815 1623 42
</p>
</div>
<!-- Each one renders an <a>, so middle-click, open-in-new-tab and
copy-address all work. -->
<div class="flex flex-wrap gap-2">
<BbButton
append:icon="lucide:external-link"
href="https://www.dhl.com/global-en/home/tracking.html"
target="_blank"
>
Track parcel
</BbButton>
<!-- `download` makes a plain file link that no router intercepts. -->
<BbButton
:download="`${order.reference}.csv`"
:href="receipt"
prepend:icon="lucide:download"
variant="outline"
>
Download receipt
</BbButton>
</div>
</div>
</template>
<script setup lang="ts">
import { BbButton } from 'bitboss-ui';
import { orders } from '~/demo-data';
const order = orders[0]!;
const receipt = `data:text/csv;charset=utf-8,${encodeURIComponent(
`reference,total,currency\n${order.reference},${order.total},${order.currency}\n`
)}`;
</script>
target, download
and external make a plain anchor
that no router intercepts. The routing rules are on
BbBaseButton.
Toggles and exclusive sets
v-model operates the button as a toggle between
true-value and false-value.
Seed the model — an undefined value swallows the first click.
For an exclusive set, share one model and give each button its own true-value.
Set each false-value to that button's own true-value (never a sibling's) and
one choice always stays selected. Set it to null instead and a second click
clears the selection.
group joins adjacent buttons into
one segment. A set of group toggles is also one Tab stop: the arrow keys move
between the buttons, and Space or Enter presses one. Keep the buttons side by
side in one parent, or the set splits.
A pressed toggle carries aria-pressed="true". Restyle it with pt:root:pressed,
not a selector of your own.
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<!-- `#prepend:icon` gets the button's states: the glyph follows
`pressed` and stays the button's icon (size, gap, spinner). -->
<BbButton v-model="notifications" variant="outline">
<template #prepend:icon="{ pressed }">
<BbIcon :icon="pressed ? 'lucide:bell-ring' : 'lucide:bell-off'" />
</template>
Notifications
</BbButton>
<span class="text-sm opacity-70">{{ notifications ? 'On' : 'Off' }}</span>
</div>
<!--
An exclusive set: one shared model, and every button's false-value
mirrors its OWN true-value, so re-clicking the active one keeps it
selected instead of switching away. No gap between them: group fuses
the borders of adjacent buttons.
-->
<div>
<BbButton
v-for="option in views"
:key="option.value"
v-model="view"
group
variant="outline"
:true-value="option.value"
:false-value="option.value"
:prepend:icon="option.icon"
>
{{ option.label }}
</BbButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbIcon } from 'bitboss-ui';
// Seed the model: an undefined model is not a toggle yet, so the first click
// would do nothing.
const notifications = ref(false);
const view = ref('board');
const views = [
{ value: 'list', label: 'List', icon: 'lucide:list' },
{ value: 'board', label: 'Board', icon: 'lucide:columns-3' },
{ value: 'table', label: 'Table', icon: 'lucide:table' },
];
</script>
Register a look, or start blank
Register a name in the plugin config and it becomes a typed value of
variant:
// nuxt.config.ts
export default defineNuxtConfig({
bitboss: { buttonVariants: ['upgrade'] },
});
The library ships no CSS for registered variants — you style
.bb-button--upgrade yourself through the same local properties as the built-in
variants. The name is also a pt state (pt:root:upgrade), so a name the
button already uses as a state (loading, pressed, …) fails the build.
.bb-button--upgrade {
--bg: var(--bb-primary);
--bg-hover: var(--bb-primary-hover);
--bg-pressed: var(--bb-primary-pressed);
--fg: var(--bb-primary-fg);
--ring-color: var(--bb-ring);
}
--ring-color paints the focus ring and --ring-size sets its width, drawn
outside the border. --ring-size defaults to var(--bb-ring-size): leave it
out unless the variant needs a heavier ring.
To start a style from scratch, pass variant="none": the button keeps its size,
padding, icons and loading, drops every colour, and your classes draw the rest.
You own the hover, the focus ring and the disabled look. (On Tailwind v4 your
classes win over the button's own rules only with
cssLayers set.)
3 files attached ·
<template>
<div class="flex flex-wrap items-center gap-6">
<!-- A pill: the colours, the hover and the focus ring are all classes. -->
<BbButton
class="rounded-full bg-(--bb-primary) px-5 text-(--bb-primary-fg) hover:bg-(--bb-primary-hover) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--bb-primary)]"
prepend:icon="lucide:sparkles"
variant="none"
>
Start free trial
</BbButton>
<!-- A text action that sits in a line of copy: no box, no height. -->
<p class="text-sm text-(--bb-text-muted)">
3 files attached ·
<BbButton
class="min-h-0 px-0 align-baseline font-medium text-(--bb-text) underline decoration-(--bb-border) underline-offset-4 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--bb-text)]"
variant="none"
>
Show all
</BbButton>
</p>
</div>
</template>
<script setup lang="ts">
import { BbButton } from 'bitboss-ui';
</script>
When the same look shows up on a second screen,
register it as a variant so it is
typed and styled in one place. When the shape is not a button at all, such as
a card or a table row, start from BbBaseButton.
Coming from v2v2 tooltip and theme props
Replace tooltip* props with v-bb-tooltip or BbTooltip. Replace theme
with a built-in or registered variant; unregistered variant names are now
type errors.