Use it for
Reach for BbSpinner when something is happening and you cannot say how far
along it is. A request in flight, a file being parsed, a panel with nothing to
show yet.
Use something else when
BbProgress, when a fraction exists: bytes sent, steps completed, rows imported- Nothing at all:
BbButton,BbTableand the form fields already carry their own loading state
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.
Load a region or row
Use a centred spinner for an empty region and a small inline one when the rest of the row remains usable. Always name what is loading.
No invoices loaded yet.
<template>
<div
class="flex max-w-sm flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
>
<div class="flex items-center justify-between gap-3">
<span class="flex items-center gap-2 text-sm font-medium">
Recent invoices
<!-- A refresh keeps the list usable: small spinner, inline. -->
<span
v-if="loading && rows.length"
class="inline-flex items-center gap-1.5 text-xs font-normal text-[color:var(--bb-text-muted)]"
role="status"
>
<BbSpinner size="sm" />
Refreshing…
</span>
</span>
<!-- Fired and forgotten: returning the promise would make the button
spin too, and one wait would show two loaders. The region below
is the only indicator. -->
<BbButton
prepend:icon="lucide:refresh-cw"
size="sm"
variant="outline"
@click="() => void load()"
>
{{ rows.length ? 'Reload' : 'Load invoices' }}
</BbButton>
</div>
<!-- The region has nothing to show, so it gets the spinner: centered,
one size up, and above a line that names what is coming. -->
<div
v-if="loading && !rows.length"
class="flex flex-col items-center gap-2 py-8 text-[color:var(--bb-text-muted)]"
role="status"
>
<BbSpinner size="lg" />
<span class="text-sm">Loading recent invoices…</span>
</div>
<p
v-else-if="!rows.length"
class="py-8 text-center text-sm text-[color:var(--bb-text-muted)]"
>
No invoices loaded yet.
</p>
<ul v-else class="flex flex-col gap-1.5" :class="{ 'opacity-60': loading }">
<li
v-for="order in rows"
:key="order.id"
class="grid grid-cols-[1fr_auto] items-baseline gap-3 text-sm sm:grid-cols-[auto_1fr_auto]"
>
<span class="font-medium tabular-nums">{{ order.reference }}</span>
<!-- Below sm the ID and the total keep the row; the name steps out. -->
<span class="hidden truncate text-[color:var(--bb-text-muted)] sm:block">
{{ userById[order.customerId]?.fullName }}
</span>
<span class="tabular-nums">{{ euros.format(order.total) }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbSpinner } from 'bitboss-ui';
import type { Order } from '~/demo-data';
import { delay, orders, userById } from '~/demo-data';
const euros = new Intl.NumberFormat('en-IE', { style: 'currency', currency: 'EUR' });
const rows = ref<Order[]>([]);
const loading = ref(false);
/**
* Nothing runs before a click, so the prerendered markup is the empty state.
* The first load fills the empty region; a reload keeps the rows on screen.
*/
async function load() {
if (loading.value) return;
loading.value = true;
rows.value = await delay(orders.slice(0, 4), 900);
loading.value = false;
}
</script>
Inline waits
A switch has no loading state of its own, so when turning one on starts a
connection, that row says so under its label, at sm so the line height stays
put.
- Connected · paid invoices sync every hour
- Not connected
<template>
<ul
class="m-0 flex max-w-sm list-none flex-col rounded-(--bb-radius) border border-(--bb-border) p-0"
>
<li
v-for="app in apps"
:key="app.id"
class="flex flex-col gap-1 border-t border-(--bb-border) px-3 py-2.5 first:border-t-0"
>
<BbSwitch
:id="`sync-${app.id}`"
v-model="app.on"
:label="`Sync invoices to ${app.name}`"
:name="`sync-${app.id}`"
@update:model-value="toggle(app)"
/>
<!-- A switch has no loading state, so the row carries the wait:
small, inline, and the other row stays usable. -->
<span
class="inline-flex h-5 items-center gap-2 text-xs text-(--bb-text-muted)"
role="status"
>
<template v-if="app.connecting">
<BbSpinner size="sm" />
Connecting to {{ app.name }}…
</template>
<template v-else>{{ app.on ? app.connected : 'Not connected' }}</template>
</span>
</li>
</ul>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { BbSpinner, BbSwitch } from 'bitboss-ui';
import { delay } from '~/demo-data';
type Integration = {
id: string;
name: string;
connected: string;
on: boolean;
connecting: boolean;
ticket: number;
};
const apps = reactive<Integration[]>([
{
id: 'stripe',
name: 'Stripe',
connected: 'Connected · paid invoices sync every hour',
on: true,
connecting: false,
ticket: 0,
},
{
id: 'xero',
name: 'Xero',
connected: 'Connected · new invoices sync nightly',
on: false,
connecting: false,
ticket: 0,
},
]);
/** Turning a row on connects; turning it off is instant. The ticket keeps a
* quick off-on-off from reporting a connection that was since cancelled. */
async function toggle(app: Integration) {
const mine = ++app.ticket;
if (!app.on) {
app.connecting = false;
return;
}
app.connecting = true;
await delay(null, 1400);
if (mine === app.ticket) app.connecting = false;
}
</script>
Size and colour
size runs from xs to 2xl, 12 to 40px, and md is the default; it also
takes a number of pixels or a CSS length. Beside helper text, use sm.
<template>
<div class="flex flex-wrap items-end gap-6">
<span class="flex flex-col items-center gap-2">
<BbSpinner size="xs" />
<span class="text-xs text-(--bb-text-muted)">xs</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="sm" />
<span class="text-xs text-(--bb-text-muted)">sm</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="md" />
<span class="text-xs text-(--bb-text-muted)">md</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="lg" />
<span class="text-xs text-(--bb-text-muted)">lg</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="xl" />
<span class="text-xs text-(--bb-text-muted)">xl</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="2xl" />
<span class="text-xs text-(--bb-text-muted)">2xl</span>
</span>
<!-- A number is pixels; a string is any CSS length. -->
<span class="flex flex-col items-center gap-2">
<BbSpinner :size="20" />
<span class="text-xs text-(--bb-text-muted)">20</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="3rem" />
<span class="text-xs text-(--bb-text-muted)">3rem</span>
</span>
</div>
</template>
<script setup lang="ts">
import { BbSpinner } from 'bitboss-ui';
</script>
A spinner takes the text colour of whatever it sits in. To change it, colour the wrapper.
<template>
<!--
No colour prop: the dots are filled with `currentColor`, so each spinner
takes the text colour of the element it sits in. Colour the wrapper.
-->
<div class="flex flex-wrap items-center gap-6 text-sm">
<span
class="inline-flex items-center gap-2 text-[color:var(--bb-text-muted)]"
>
<BbSpinner size="sm" />
Checking availability
</span>
<span class="inline-flex items-center gap-2 text-[color:var(--bb-primary)]">
<BbSpinner size="sm" />
Syncing catalogue
</span>
<span class="inline-flex items-center gap-2 text-[color:var(--bb-danger)]">
<BbSpinner size="sm" />
Retrying after an error
</span>
</div>
</template>
<script setup lang="ts">
import { BbSpinner } from 'bitboss-ui';
</script>
Under prefers-reduced-motion: reduce the dots hold at full size instead of
pulsing.
Coming from v2
The color prop is gone. Colour the surrounding text instead.
Where you no longer need one
Most waits belong to a component that already handles them. A @click handler
returning a promise puts BbButton into its own loading state, and the form
fields carry a loading of their own.
<template>
<div class="flex flex-wrap items-center gap-3">
<!--
There is no BbSpinner in this file, and that is the point. The click
handler returns a promise, so the button shows its own spinner, blocks
further clicks and sets the ARIA for as long as the promise is pending.
-->
<BbButton variant="primary" @click="publish">Publish catalogue</BbButton>
<span class="text-sm text-(--bb-text-muted)">{{ status }}</span>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton } from 'bitboss-ui';
import { delay } from '~/demo-data';
const status = ref('128 products in draft.');
async function publish() {
status.value = 'Publishing…';
await delay(null, 1400);
status.value = '128 products live.';
}
</script>
Nesting a BbSpinner inside a button paints the picture without the behaviour:
the button stays clickable and assistive tech is told nothing.
A field with loading shows the spinner inside itself, so the status line under
it can stay plain text.
<template>
<div class="flex max-w-sm flex-col gap-2">
<!-- The field shows its own spinner while `loading` is on: no BbSpinner here. -->
<BbTextInput
id="invoice-note"
v-model="note"
label="Invoice note"
:loading="saving"
name="invoiceNote"
placeholder="Visible to the customer"
@update:model-value="save"
/>
<!-- Text only: the spinner already sits in the field. -->
<span class="h-5 text-xs text-(--bb-text-muted)" role="status">
{{ saving ? 'Saving…' : saved ? 'All changes saved' : 'Saves as you type' }}
</span>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
const note = ref<string | null>('');
const saving = ref(false);
const saved = ref(false);
// A ticket per keystroke: only the last save in flight is allowed to report
// success, so a fast typist never sees "saved" while a later save is pending.
let ticket = 0;
async function save() {
const mine = ++ticket;
saving.value = true;
saved.value = false;
await delay(null, 700);
if (mine !== ticket) return;
saving.value = false;
saved.value = true;
}
</script>
Where the shape of the incoming content is known and stable, placeholder rows
say more than a spinner. BbTable ships them behind loading, and a column's
skeleton option picks each placeholder's shape.
Coming from v2
BbButton tracking async click handlers is new in v3. The spinner you placed
inside a button, and the ref that drove it, can both go.
Announcing the wait
Announce the wait exactly once: label and role="status" together read it out
twice.
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-xs font-medium text-(--bb-text-muted)">With visible text</span>
<!--
The sentence is already on screen, so the live region goes on the
container that holds it and the spinner stays decorative. No `label`
here: it would be announced a second time.
-->
<span
class="inline-flex items-center gap-2 self-start text-sm"
role="status"
>
<BbSpinner size="sm" />
Loading recent activity…
</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium text-(--bb-text-muted)">Standing alone</span>
<!--
Nothing beside it says what is happening, so the spinner has to say it
itself: `label` wraps it in role="status" with the text rendered
sr-only.
-->
<div class="flex items-center gap-3 self-start text-sm">
<span>Activity</span>
<BbSpinner label="Loading recent activity" size="sm" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { BbSpinner } from 'bitboss-ui';
</script>
When visible text sits beside the spinner, put role="status" on the container
holding both and leave label unset. When the spinner stands alone, label
wraps it in its own role="status" with the text rendered sr-only.
When the wait becomes measurable
Some waits change nature halfway through. A CSV import validates the file first, with nothing to count, then writes rows it can count.
<template>
<div
class="flex max-w-sm flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium">Import team members</span>
<span class="text-xs text-[color:var(--bb-text-muted)]">
team-export.csv · {{ users.length }} rows
</span>
</div>
<!-- Fired and forgotten, so the button does not spin beside the status
line: one wait, one indicator. -->
<BbButton
prepend:icon="lucide:upload"
size="sm"
variant="outline"
@click="() => void run()"
>
Import
</BbButton>
</div>
<!-- Parsing and validating: no fraction exists yet, so it spins. -->
<div
v-if="phase === 'validating'"
class="flex items-center gap-2 text-sm text-[color:var(--bb-text-muted)]"
role="status"
>
<BbSpinner size="sm" />
Validating team-export.csv…
</div>
<!-- Rows are now countable, so the fraction exists: hand off to the bar. -->
<div v-else-if="phase === 'importing'" class="flex flex-col gap-1.5">
<BbProgress
label="Importing team members"
:max="users.length"
:model-value="imported"
/>
<span class="text-xs tabular-nums text-[color:var(--bb-text-muted)]">
{{ imported }} of {{ users.length }} rows imported
</span>
</div>
<p v-else-if="phase === 'done'" class="text-sm">
{{ users.length }} team members imported.
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbProgress, BbSpinner } from 'bitboss-ui';
import { delay, users } from '~/demo-data';
type Phase = 'idle' | 'validating' | 'importing' | 'done';
const phase = ref<Phase>('idle');
const imported = ref(0);
/**
* Every step is an awaited `delay`, started by the click. Nothing ticks before
* the reader asks for it, so the prerendered markup and the hydrated markup are
* the same idle card.
*/
async function run() {
if (phase.value === 'validating' || phase.value === 'importing') return;
phase.value = 'validating';
imported.value = 0;
await delay(null, 1000);
phase.value = 'importing';
for (let done = 5; done < users.length; done += 5) {
await delay(null, 500);
imported.value = done;
}
await delay(null, 500);
imported.value = users.length;
phase.value = 'done';
}
</script>
Spin while the number does not exist, and fill a bar from the moment it does.
When a bar is already on screen, keep it and set indeterminate on
BbProgress instead.