Use it for
BbBadge is a small inline pill: a status label (Active, Beta), a short
count, a token the user can dismiss.
Use something else when
BbIndicator, when the marker belongs on top of something else: an unread count on a bell, a presence dot on an avatarBbTag, when the token is an input people type free-form values intoBbButton, when it is a standalone action
Also see: Design language.
Coming from v2
Two v2 names land here. The v2 BbBadge, the count anchored to an element, is
now BbIndicator: rename the tag and move content to text. BbChip is this
badge with clearable, which you now have to write yourself.
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.
Status labels
Write the label inside the badge; there is no text or content prop. Map
each state to one variant and one icon in a single table, so colour is never the
only signal.
| ORD-2026-0511 | Sanne Jonker | €199.00 | Pending |
| ORD-2026-0507 | Andrei Popescu | €627.90 | Pending |
| ORD-2026-0480 | Anneke de Vries | €1,286.80 | Processing |
| ORD-2026-0491 | Yuki Tanaka | €958.90 | Shipped |
| ORD-2026-0485 | Leila Nourani | €84.50 | Cancelled |
| ORD-2026-0469 | Zara Ahmadi | €249.00 | Delivered |
| ORD-2026-0421 | Rafael Costa | €899.00 | Refunded |
<template>
<div class="flex max-w-lg flex-col gap-3 text-sm">
<div
aria-label="Filter by status"
class="flex flex-wrap gap-1"
role="group"
>
<BbButton
v-for="tab in tabs"
:key="tab.value"
v-model="filter"
:false-value="tab.value"
size="sm"
:true-value="tab.value"
variant="ghost"
>
{{ tab.label }}
<BbBadge
size="sm"
:variant="filter === tab.value ? 'primary' : 'secondary'"
>
{{ tab.count }}
</BbBadge>
</BbButton>
</div>
<div class="overflow-x-auto">
<table class="w-full whitespace-nowrap">
<tbody>
<tr
v-for="order in shown"
:key="order.id"
class="border-t border-[color:var(--bb-border)]"
>
<td class="py-2 pr-3 font-mono text-xs">{{ order.reference }}</td>
<td class="hidden py-2 pr-3 sm:table-cell">
{{ userById[order.customerId]!.fullName }}
</td>
<td class="py-2 pr-3 text-right tabular-nums">
{{ price.format(order.total) }}
</td>
<td class="py-2 text-right">
<!-- Icon plus word: the tone is never the only signal. -->
<BbBadge
:prepend:icon="tone[order.status].icon"
:variant="tone[order.status].variant"
>
{{ orderStatusLabels[order.status] }}
</BbBadge>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbButton } from 'bitboss-ui';
import {
orderById,
orderStatusLabels,
userById,
type OrderStatus,
} from '~/demo-data';
/** One place maps state onto a tone and a complete icon name. */
const tone = {
pending: { variant: 'outline', icon: 'lucide:clock' },
processing: { variant: 'secondary', icon: 'lucide:loader' },
shipped: { variant: 'secondary', icon: 'lucide:truck' },
delivered: { variant: 'primary', icon: 'lucide:check' },
cancelled: { variant: 'destructive', icon: 'lucide:x' },
refunded: { variant: 'outline', icon: 'lucide:undo-2' },
} as const satisfies Record<OrderStatus, object>;
const price = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const filter = ref<'all' | OrderStatus>('all');
const recent = [9020, 9019, 9014, 9016, 9015, 9012, 9003].map(
(id) => orderById[id]!
);
const tabs = (['all', 'pending', 'processing', 'delivered'] as const).map(
(value) => ({
value,
label: value === 'all' ? 'All' : orderStatusLabels[value],
count: recent.filter((o) => value === 'all' || o.status === value).length,
})
);
const shown = computed(() =>
recent.filter((o) => filter.value === 'all' || o.status === filter.value)
);
</script>
A badge is inert until you add clearable or a BbBadgeButton.
Variants and sizes
variant picks the tone of the pill: primary, secondary, outline,
destructive. primary is the default. Sizes run from xs to 2xl, and
md, the default, is the one meant for a line of running text.
<template>
<div class="flex flex-col gap-5">
<!--
A grid from sm up, with the sizes as column heads. Below sm each variant
wraps under its own label and every badge carries its size underneath.
-->
<div
class="flex flex-col gap-1.5 text-xs sm:grid sm:grid-cols-[6rem_repeat(6,max-content)] sm:items-center sm:gap-x-3 sm:gap-y-3"
>
<span class="hidden sm:block" />
<span
v-for="size in sizes"
:key="size"
:class="[mono, 'hidden sm:block']"
>
{{ size }}
</span>
<template v-for="variant in variants" :key="variant">
<span :class="[mono, 'mt-2 sm:mt-0']">{{ variant }}</span>
<div class="flex flex-wrap items-end gap-3 sm:contents">
<span
v-for="size in sizes"
:key="size"
class="flex flex-col items-start gap-1"
>
<BbBadge :size="size" :variant="variant">{{
labels[variant]
}}</BbBadge>
<span :class="[mono, 'sm:hidden']">{{ size }}</span>
</span>
</div>
</template>
</div>
<div class="flex flex-wrap items-center gap-2">
<BbBadge prepend:icon="lucide:credit-card">Paid</BbBadge>
<BbBadge append:icon="lucide:arrow-right" variant="outline"
>Continue</BbBadge
>
<!-- Icon-only: the slot text becomes the accessible name. -->
<BbBadge icon="lucide:badge-check" variant="secondary">Verified</BbBadge>
<span class="mx-2 h-5 w-px bg-[color:var(--bb-border)]" />
<BbButton size="sm" variant="outline">Filters</BbButton>
<BbBadge size="xl" variant="secondary">Beside an sm button: xl</BbBadge>
</div>
</div>
</template>
<script setup lang="ts">
import { BbBadge, BbButton } from 'bitboss-ui';
const variants = ['primary', 'secondary', 'outline', 'destructive'] as const;
const sizes = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const;
const mono = 'font-mono text-(--bb-text-muted)';
const labels = {
primary: 'New',
secondary: 'Draft',
outline: 'v2.4',
destructive: 'Overdue',
};
</script>
Keep destructive for what is actually wrong. There is no success variant,
and an unregistered name colours nothing, so
register the tones you need. variant="none" drops the variant
class entirely, for pills you colour yourself.
Beside a control the badge runs larger than the button it pairs with: lg next
to xs, xl next to sm, 2xl next to md.
Icons
prepend:icon and append:icon put an icon beside the label. With icon the
badge goes icon-only and the slot becomes its accessible name, so keep writing
the label. An empty one is an error for npx bitboss-ui check and the ESLint
rule require-accessible-name.
The slots named after those props (#icon, #prepend:icon, #append:icon)
replace them inside the icon part, so pt:icon, --icon-size and the
loading spinner still apply. Their scope is the badge's states,
{ loading, activeLink, exactActiveLink }, so a chip that links to a page can
show a solid glyph while that page is open.
Removable tokens
clearable adds a trailing clear button and emits click:clear when it is
pressed.
<template>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-(--bb-text-muted)">Filtered by</span>
<!--
The badge is controlled: it emits, it does not remove itself. No
clearable-label here — the × names itself "Remove <the chip's text>".
The chips are xl because the button they sit beside is sm.
-->
<BbBadge
v-for="category in selected"
:key="category"
clearable
prepend:icon="lucide:tag"
size="xl"
variant="secondary"
@click:clear="remove(category)"
>
{{ category }}
</BbBadge>
<BbButton size="sm" variant="ghost" @click="reset">
{{ selected.length ? 'Clear all' : 'Restore filters' }}
</BbButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbButton } from 'bitboss-ui';
import { productCategories } from '~/demo-data';
const initial = productCategories.slice(0, 3);
const selected = ref<string[]>([...initial]);
function remove(category: string) {
selected.value = selected.value.filter((entry) => entry !== category);
}
function reset() {
selected.value = selected.value.length ? [] : [...initial];
}
</script>
The badge does not remove itself: taking the item out of your own state is your
job. The × is named "Remove" plus the badge's text, so a chip reading Italy
announces "Remove Italy" with no wiring. A
clearable-label is the ×'s
whole name, on any body: write the full phrase, not a verb. An append:icon shares the trailing
slot with the ×, which then shows only on hover or focus, so lead removable
chips with prepend:icon.
Clickable badges
To make the pill's body clickable, slot a BbBadgeButton as a direct child
of the default slot. The clear button stays beside it, with its own tab stop.
Press the label to change the value, the × to drop the filter. They are two tab stops.
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<!--
Two controls in one pill: BbBadgeButton owns the body, the clear
button is its sibling. Clicking the × never fires the body's click.
With a BbBadgeButton inside, the × alone would be named "Remove",
so clearable-label says what it removes.
-->
<BbBadge
v-if="applied"
clearable
clearable-label="Remove priority filter"
prepend:icon="lucide:flag"
size="xl"
variant="secondary"
@click:clear="applied = false"
>
<BbBadgeButton @click="cycle">Priority: {{ priority }}</BbBadgeButton>
</BbBadge>
<BbButton v-else size="sm" variant="outline" @click="applied = true">
Add priority filter
</BbButton>
</div>
<p class="text-sm text-(--bb-text-muted)">
Press the label to change the value, the × to drop the filter. They are
two tab stops.
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbBadgeButton, BbButton } from 'bitboss-ui';
import { priorities } from '~/demo-data';
const applied = ref(true);
const priority = ref('High');
function cycle() {
const index = priorities.indexOf(priority.value);
const next = priorities.at((index + 1) % priorities.length);
if (next) priority.value = next;
}
</script>
With a BbBadgeButton in the body the × is named by the bare verb, "Remove",
so give it a clearable-label
that says what goes. BbBadgeButton composes BbBaseButton, so it also takes
href, to, target, rel and disabled. On Inertia, method needs href:
only href becomes a visit, and to is a router link.
A link body's slot gets { activeLink, exactActiveLink }, and the state
reaches the whole chip: pt:root:active-link and the icon slots. Whatever
renders outside the button (the badge's root, and the × side of a clearable
badge) gets it only once the page hydrates; the label and the leading icon have
it in the server HTML.
Changelog is disabled until the release ships: no href, out of the tab order.
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<!-- BbBadgeButton composes BbBaseButton, so href renders a real <a>. -->
<BbBadge prepend:icon="lucide:external-link" variant="outline">
<BbBadgeButton
href="https://www.npmjs.com/package/bitboss-ui"
rel="noopener"
target="_blank"
>
npm
</BbBadgeButton>
</BbBadge>
<BbBadge prepend:icon="lucide:link" variant="secondary">
<BbBadgeButton href="#styling">Jump to styling</BbBadgeButton>
</BbBadge>
<!-- A disabled link renders the <a> without an href and leaves the tab order. -->
<BbBadge variant="secondary">
<BbBadgeButton disabled href="https://example.com"
>Changelog</BbBadgeButton
>
</BbBadge>
</div>
<p class="m-0 text-xs text-(--bb-text-muted)">
Changelog is disabled until the release ships: no href, out of the tab
order.
</p>
</div>
</template>
<script setup lang="ts">
import { BbBadge, BbBadgeButton } from 'bitboss-ui';
</script>
Nothing warns you about two mistakes. Wrapping the BbBadgeButton in another
element duplicates the label. Slotting a plain <button> instead nests it
inside the badge's own button once the badge is clearable.
Loading
loading swaps a spinner in for the leftmost icon: the trailing one when that is
the only icon, a leading slot of its own when there are none.
TCK-1042 · Printer on floor 3 is offline
<template>
<section
aria-labelledby="ticket-1042"
class="flex max-w-md flex-col gap-2 rounded-(--bb-radius) border border-(--bb-border) p-3"
>
<h3 id="ticket-1042" class="m-0 text-sm font-semibold">
TCK-1042 · Printer on floor 3 is offline
</h3>
<!-- Each chip saves its own field, so only the chip being saved spins. -->
<div class="flex flex-wrap items-center gap-2">
<!-- Only a trailing icon: the spinner takes that slot. -->
<BbBadge
append:icon="lucide:chevron-down"
:loading="saving.status"
variant="secondary"
>
<BbBadgeButton @click="save('status')"
>Status: {{ status }}</BbBadgeButton
>
</BbBadge>
<!-- A leading icon is always the leftmost, so the spinner replaces it. -->
<BbBadge
:loading="saving.assignee"
prepend:icon="lucide:user"
variant="outline"
>
<BbBadgeButton @click="save('assignee')">{{ assignee }}</BbBadgeButton>
</BbBadge>
<!-- No icons at all: the spinner opens a leading slot of its own. -->
<BbBadge :loading="saving.priority" variant="outline">
<BbBadgeButton @click="save('priority')"
>Priority: {{ priority }}</BbBadgeButton
>
</BbBadge>
</div>
</section>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbBadge, BbBadgeButton } from 'bitboss-ui';
import { delay } from '~/demo-data';
type Field = 'status' | 'assignee' | 'priority';
const statuses = ['Open', 'In progress', 'Resolved'];
const priorities = ['Normal', 'High', 'Low'];
const status = ref(statuses[0]!);
const assignee = ref('Unassigned');
const priority = ref(priorities[0]!);
const saving = reactive<Record<Field, boolean>>({
status: false,
assignee: false,
priority: false,
});
const next = (list: string[], current: string) =>
list[(list.indexOf(current) + 1) % list.length]!;
/**
* No guard against a double press: while a chip is loading its body ignores
* clicks, so pressing it again cannot save twice.
*/
async function save(field: Field) {
saving[field] = true;
await delay(null, 1200);
if (field === 'status') status.value = next(statuses, status.value);
if (field === 'priority') priority.value = next(priorities, priority.value);
if (field === 'assignee')
assignee.value =
assignee.value === 'Unassigned' ? 'Assigned to you' : 'Unassigned';
saving[field] = false;
}
</script>
A loading badge ignores clicks on its BbBadgeButton: your @click does not
fire, a link does not navigate and the body leaves the tab order. Set loading
on the one badge whose action is saving, and a second press cannot run it twice.
Custom tones
For a tone that recurs across the product, register a variant in the plugin config instead of colouring pills one at a time.
// nuxt.config.ts
export default defineNuxtConfig({
bitboss: { badgeVariants: ['soft-green'] },
});
The registered name joins the TypeScript union behind variant and gives you a
.bb-badge--soft-green hook. You write the CSS, setting the two custom
properties the built-in variants drive. Mix them from a theme token, so the pill
follows dark mode and a retuned palette.
.bb-badge.bb-badge--soft-green {
--bg: color-mix(in oklab, var(--bb-success) 15%, var(--bb-panel));
--fg: color-mix(in oklab, var(--bb-success) 55%, var(--bb-text));
}
The same class reaches past color: --bw with --border-color draws a border,
--r rounds the pill, --fs and --px set the label size and side padding,
and --ring-color with --ring-size style the focus ring of a clickable badge.
For a one-off accent, override the same properties inline:
:style="{ '--bg': 'var(--bb-info)', '--fg': 'var(--bb-info-fg)' }". With a
colour of your own, check the contrast yourself.
When the colours come from data, such as labels each workspace colours its own
way, there is no name to register. Set variant="none": the badge renders no
variant class and you paint it with --bg and --fg. Until you set them it
keeps the base primary colours.
<template>
<div class="flex flex-wrap items-center gap-2">
<!--
Label colors come from user data, so there is no variant to register.
variant="none" drops the variant class; --bg and --fg paint the pill.
-->
<BbBadge
v-for="label in labels"
:key="label.name"
:style="{
'--bg': `color-mix(in oklab, ${label.color} 18%, var(--bb-panel))`,
'--fg': `color-mix(in oklab, ${label.color} 55%, var(--bb-text))`,
}"
variant="none"
>
{{ label.name }}
</BbBadge>
</div>
</template>
<script setup lang="ts">
import { BbBadge } from 'bitboss-ui';
// Colors each workspace picks for its own labels.
const labels = [
{ name: 'Backend', color: '#2563eb' },
{ name: 'Needs design', color: '#db2777' },
{ name: 'Good first issue', color: '#16a34a' },
{ name: 'Blocked', color: '#ea580c' },
];
</script>
Coming from v2v2 stylesheets
.bb-chip selectors become .bb-badge, the label span is .bb-badge__text and
the × is .bb-badge__clear (it was __clear-button). The pill now holds two
sides, .bb-badge__body and .bb-badge__trailing, so a direct-child selector
such as .bb-badge > .bb-badge__text has to become a descendant one.
npx bitboss-ui check flags each stale .bb-* selector and names the class to use.