Use it for
Reach for BbIndicator to pin a marker onto another element. An unread count on
a bell, a presence dot on an avatar, a New flag on a nav item.
Use something else when
BbBadge, when the marker is an inline label that belongs in the text, withclearablewhen it can be dismissed
Coming from v2
This component is what v2 called BbBadge. The port is mechanical: rename the
tag, move content to text and color to variant, drop floating. In CSS,
.bb-badge becomes .bb-indicator.
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.
Unread counts
Put the element you are marking in the slot, never the other way round:
BbIndicator wraps it and pins text to its top-right corner.
<template>
<div class="flex max-w-lg flex-col gap-4">
<header
class="flex items-center gap-2 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] px-3 py-2"
>
<span class="mr-auto text-sm font-semibold">Vantera</span>
<BbIndicator :text="2" variant="info">
<BbButton icon="lucide:message-square" variant="ghost"
>Messages</BbButton
>
</BbIndicator>
<!--
`count || undefined` is what hides the bubble at zero: passing 0 would
render the string "0", which is content like any other.
-->
<BbIndicator :max="99" :text="count || undefined" variant="destructive">
<BbButton icon="lucide:bell" variant="ghost">Notifications</BbButton>
</BbIndicator>
<BbIndicator bottom class="ml-2" dot variant="success">
<BbAvatar :alt="`${me.fullName} (online)`" size="32" :src="me.avatar" />
</BbIndicator>
</header>
<div class="flex flex-wrap items-center gap-2">
<BbButton size="sm" variant="secondary" @click="count += 1"
>One more</BbButton
>
<BbButton size="sm" variant="secondary" @click="count += 120">
120 more
</BbButton>
<BbButton size="sm" variant="ghost" @click="count = 0"
>Mark all read</BbButton
>
<span class="text-sm text-[color:var(--bb-text-muted)]">{{
status
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbAvatar, BbButton, BbIndicator } from 'bitboss-ui';
import { users } from '~/demo-data';
const me = users[0]!;
const count = ref(3);
const status = computed(() =>
count.value
? `${count.value} unread notification${count.value === 1 ? '' : 's'}`
: 'Inbox clear'
);
</script>
text also takes a short string such as New; keep it to a word or two,
and use xs or sm so it fits.
The indicator is inert. When the marker has to be clickable, put a BbButton in
the slot.
Presence dots
dot replaces the bubble's content with a plain filled marker, the right shape
for the state of a person or a service.
Marta Villoresi
Online
Lukas Brandt
Away
Sofia Marchetti
In a call
<template>
<div class="flex flex-wrap items-center gap-6">
<div v-for="row in roster" :key="row.user.id" class="flex items-center gap-2">
<!-- bottom keeps the dot clear of the initials. -->
<BbIndicator bottom dot :variant="row.variant">
<BbAvatar :alt="row.user.fullName" size="40">
{{ row.user.initials }}
</BbAvatar>
</BbIndicator>
<div class="text-sm">
<p class="font-medium">{{ row.user.fullName }}</p>
<p class="text-xs text-(--bb-text-muted)">{{ row.presence }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { BbAvatar, BbIndicator } from 'bitboss-ui';
import type { IndicatorVariantType } from 'bitboss-ui';
import { users } from '~/demo-data';
// A dot carries no content, so presence lives in its color, and in the text
// beside it, because a color on its own is not a label.
const presences: { presence: string; variant: IndicatorVariantType }[] = [
{ presence: 'Online', variant: 'success' },
{ presence: 'Away', variant: 'warning' },
{ presence: 'In a call', variant: 'destructive' },
];
const fallback = { presence: 'Online', variant: 'success' } as const;
const roster = users
.slice(0, 3)
.map((user, index) => ({ user, ...(presences.at(index) ?? fallback) }));
</script>
It wins over text rather than combining with it. And a colour on its own is not
a label: write the state beside it, as the example does.
Placement, colour and size
bottom and left combine to move the marker to any of the four corners.
top right
top left
bottom right
bottom left
default
success
info
destructive
default
success
info
warning
destructive
xs
sm
md
lg
xl
2xl
<template>
<!-- Each row wraps, so at phone width the label sits on its own line. -->
<div class="flex flex-col gap-6 text-xs">
<div :class="row">
<span :class="head">corner</span>
<div :class="items">
<div v-for="corner in corners" :key="corner.label" class="text-center">
<BbIndicator :bottom="corner.bottom" :left="corner.left" :text="3">
<BbButton icon="lucide:bell" variant="outline"
>Notifications</BbButton
>
</BbIndicator>
<p :class="caption">{{ corner.label }}</p>
</div>
</div>
</div>
<!-- warning is left out of the counts: white text on amber is too faint
for a number. It stays in the dot row below. -->
<div :class="row">
<span :class="head">variant</span>
<div :class="items">
<div
v-for="variant in countVariants"
:key="variant"
class="text-center"
>
<BbIndicator :text="12" :variant="variant">
<BbButton icon="lucide:inbox" variant="outline">Inbox</BbButton>
</BbIndicator>
<p :class="caption">{{ variant }}</p>
</div>
</div>
</div>
<div :class="row">
<span :class="head">dot</span>
<div :class="items">
<div v-for="variant in variants" :key="variant" class="text-center">
<BbIndicator bottom dot :variant="variant">
<BbAvatar alt="Ada Lovelace" size="32">AL</BbAvatar>
</BbIndicator>
<p :class="caption">{{ variant }}</p>
</div>
</div>
</div>
<!-- Match the marker to the element: an xs avatar takes an xs dot. -->
<div :class="row">
<span :class="head">size</span>
<div :class="items">
<div v-for="size in sizes" :key="size" class="text-center">
<BbIndicator bottom dot :size="size" variant="success">
<BbAvatar alt="Ada Lovelace" :size="size">AL</BbAvatar>
</BbIndicator>
<p :class="caption">{{ size }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { BbAvatar, BbButton, BbIndicator } from 'bitboss-ui';
const corners = [
{ label: 'top right', bottom: false, left: false },
{ label: 'top left', bottom: false, left: true },
{ label: 'bottom right', bottom: true, left: false },
{ label: 'bottom left', bottom: true, left: true },
];
const variants = [
'default',
'success',
'info',
'warning',
'destructive',
] as const;
const countVariants = variants.filter((variant) => variant !== 'warning');
const sizes = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const;
const row = 'flex flex-col gap-1 sm:flex-row sm:items-end sm:gap-5';
const head = 'w-16 shrink-0 font-mono text-(--bb-text-muted) sm:pb-6';
const items = 'flex flex-wrap items-end gap-x-6 gap-y-4 pt-3';
const caption = 'm-0 mt-2 font-mono text-(--bb-text-muted)';
</script>
The bottom suits a presence dot, clear of a face or initials; the top suits a count, clear of a button's label.
Status colours
variant maps the shared status colors: default, success, info,
warning, destructive.
Red on a count means "needs attention". If every marker in the app is
destructive, none of them is. warning suits a dot rather than a count: white
digits on amber are too faint to read at bubble size.
Sizes
Sizes run from xs to 2xl; md is the default.
Pick the size from the element you are decorating, not from the text in the bubble. A 56px avatar and a toolbar button do not take the same marker.
Counts that change
Bind the count you already keep and cap it with max: above the threshold the
bubble renders ${max}+.
Zero shows, so hide the marker by withholding text altogether:
:text="count || undefined". The bubble is a live region and announces the new
count on its own, so wrap a control that already has an accessible name.
Custom colours
Register a name in the plugin config for a colour that recurs, and it becomes a
typed value of variant.
// nuxt.config.ts
export default defineNuxtConfig({
bitboss: { indicatorVariants: ['mention'] },
});
The library ships no CSS for registered variants. You style
.bb-indicator--mention yourself, setting the two custom properties the built-in
variants drive. Mix them from theme tokens, so the bubble follows dark mode.
.bb-indicator.bb-indicator--mention {
--bg: color-mix(in oklab, var(--bb-info) 80%, var(--bb-primary));
--fg: var(--bb-primary-fg);
}
Past colour, --bw with --border-color rings the bubble so it separates from
a busy anchor, --fs sizes the count, and --px widens the pill as the count
grows. --offset sets how far the bubble overhangs the corner ([--offset:0px]
on class tucks it in), and pt:bubble="ring-2 ring-(--bb-panel)" lifts it off
a busy button.
For a one-off, such as a colour that comes from data, override the same
properties inline: :style="{ '--bg': team.color, '--fg': team.textColor }".
With a colour of your own, check the contrast yourself.