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
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
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.
Status labels
Write the label inside the badge; there is no text or content prop.
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-2">
<BbBadge variant="primary">New</BbBadge>
<BbBadge variant="secondary">Draft</BbBadge>
<BbBadge variant="outline">v2.4.0</BbBadge>
<BbBadge variant="destructive">Overdue</BbBadge>
</div>
<div class="flex flex-wrap items-center gap-2">
<BbBadge prepend:icon="lucide:credit-card" variant="primary"
>Paid</BbBadge
>
<BbBadge append:icon="lucide:arrow-right" variant="outline">
Continue
</BbBadge>
<BbBadge icon="lucide:badge-check" variant="secondary">Verified</BbBadge>
</div>
<div class="flex flex-wrap items-center gap-2">
<BbBadge size="md" variant="secondary">In text</BbBadge>
<BbBadge size="xl" variant="secondary">Beside a small control</BbBadge>
<BbButton size="sm" variant="outline">Filters</BbButton>
</div>
</div>
</template>
<script setup lang="ts">
import { BbBadge, BbButton } from 'bitboss-ui';
</script>
A badge is inert until you add clearable or a BbBadgeButton.
Variants
variant picks the tone of the pill: primary, secondary, outline,
destructive. primary is the default.
Keep destructive for what is actually wrong. There is no success variant,
and an unregistered name colors nothing, so
register the tones you need. variant="none" drops the variant
class entirely, for pills you color yourself.
Sizes
Sizes run from xs to 2xl, md is the default and the one meant for a line
of running text.
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.
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 opacity-70">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.
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.
-->
<BbBadge
v-if="applied"
append:icon="lucide:chevron-down"
clearable
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 opacity-70">
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>
BbBadgeButton composes BbBaseButton, so it also takes href, to,
target, rel and disabled.
<template>
<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, so it cannot fire. -->
<BbBadge variant="secondary">
<BbBadgeButton disabled href="https://example.com">Changelog</BbBadgeButton>
</BbBadge>
</div>
</template>
<script setup lang="ts">
import { BbBadge, BbBadgeButton } from 'bitboss-ui';
</script>
Both constraints are silent. Wrap the companion in another element and you are
left with a duplicated label, and a plain <button> of your own nests inside
another button as soon as 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.
<template>
<div class="flex flex-col gap-4">
<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" variant="secondary">
Status: Open
</BbBadge>
<!-- A leading icon is always the leftmost, so the spinner replaces it. -->
<BbBadge :loading="saving" prepend:icon="lucide:credit-card" variant="outline">
Payment
</BbBadge>
<!-- No icons at all: the spinner opens a leading slot of its own. -->
<BbBadge :loading="saving" variant="outline">Syncing</BbBadge>
<!--
A clickable badge that saves on click. No guard in save(): while
loading, the body ignores clicks, so pressing it again cannot save twice.
-->
<BbBadge :loading="saving" prepend:icon="lucide:star" variant="secondary">
<BbBadgeButton @click="save">Stars: {{ saves }}</BbBadgeButton>
</BbBadge>
</div>
<div>
<BbButton :loading="saving" size="sm" variant="outline" @click="save">Save</BbButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbBadgeButton, BbButton } from 'bitboss-ui';
import { delay } from '~/demo-data';
const saving = ref(false);
const saves = ref(0);
async function save() {
saving.value = true;
await delay(null, 1600);
saves.value += 1;
saving.value = 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
while the action saves 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 coloring 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.
.bb-badge.bb-badge--soft-green {
--bg: color-mix(in oklab, #16a34a 15%, var(--bb-panel));
--fg: color-mix(in oklab, #16a34a 80%, var(--bb-text));
}
For a one-off accent, override the same properties inline,
:style="{ '--bg': '#7c3aed' }", and check the contrast yourself.
When the colors come from data, such as labels each workspace colors 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 colors.
<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} 75%, 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-badge, .bb-badge__content, .bb-badge__clear-button and the variant and
size modifiers all still match. Only direct-child selectors broke: the pill now
holds two sides, .bb-badge__body and .bb-badge__trailing, so
.bb-badge > .bb-badge__content has to become a descendant selector.