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
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Load a region or row
Use a centered 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="text-sm font-medium">Recent invoices</span>
<!-- The trigger looks after itself: the handler is async, so the
button shows its own busy state without a spinner from us. -->
<BbButton
prepend:icon="lucide:refresh-cw"
size="sm"
variant="outline"
@click="load"
>
{{ rows.length ? 'Reload' : 'Load invoices' }}
</BbButton>
</div>
<!-- The region has nothing to show, so it gets the spinner — centred,
one size up, and above a line that names what is coming. -->
<div
v-if="loading"
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">
<li
v-for="order in rows"
:key="order.id"
class="grid grid-cols-[auto_1fr_auto] items-baseline gap-3 text-sm"
>
<span class="font-medium tabular-nums">{{ order.reference }}</span>
<span class="truncate text-[color:var(--bb-text-muted)]">
{{ userById[order.customerId]?.fullName }}
</span>
<span class="tabular-nums">€{{ order.total.toFixed(2) }}</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 rows = ref<Order[]>([]);
const loading = ref(false);
/** Nothing runs before a click, so the prerendered markup is the empty state. */
async function load() {
loading.value = true;
rows.value = [];
rows.value = await delay(orders.slice(0, 4), 900);
loading.value = false;
}
</script>
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, BbTable has
loading with skeleton rows, 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 opacity-70">{{ 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('Draft — 128 products.');
async function publish() {
status.value = 'Publishing…';
await delay(null, 1400);
status.value = 'Published — 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.
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.
Size and color
Sizes run from xs to 2xl, 12 to 40px, and md is the default. 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 opacity-70">xs</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="sm" />
<span class="text-xs opacity-70">sm</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="md" />
<span class="text-xs opacity-70">md</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="lg" />
<span class="text-xs opacity-70">lg</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="xl" />
<span class="text-xs opacity-70">xl</span>
</span>
<span class="flex flex-col items-center gap-2">
<BbSpinner size="2xl" />
<span class="text-xs opacity-70">2xl</span>
</span>
<!-- A number is pixels; a string is any CSS length. -->
<span class="flex flex-col items-center gap-2">
<BbSpinner size="3rem" />
<span class="text-xs opacity-70">3rem</span>
</span>
</div>
</template>
<script setup lang="ts">
import { BbSpinner } from 'bitboss-ui';
</script>
A spinner takes the text color of whatever it sits in. To recolor it, color 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. Color the surrounding text instead.
Inline waits
A region with nothing to show yet gets a large spinner, centered, above a line naming what is coming.
When the wait belongs to a single field or row, keep the spinner there and keep it small.
<template>
<div class="flex max-w-sm flex-col gap-2">
<BbTextInput
id="invoice-note"
v-model="note"
label="Invoice note"
name="invoiceNote"
placeholder="Visible to the customer"
@update:model-value="save"
/>
<!-- Inline: the wait belongs to one field, so the spinner stays small and
next to it. Nothing else on the form is blocked. -->
<span
class="inline-flex h-5 items-center gap-2 text-xs text-[color:var(--bb-text-muted)]"
role="status"
>
<template v-if="saving">
<BbSpinner size="xs" />
Saving…
</template>
<template v-else-if="saved">All changes saved</template>
</span>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSpinner, 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 skeleton.
Announcing the wait
Announce the wait exactly once; setting up both routes reads it out twice.
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-xs font-medium opacity-70">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 opacity-70">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>
<BbButton
prepend:icon="lucide:upload"
size="sm"
variant="outline"
@click="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() {
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, 220);
imported.value = done;
}
await delay(null, 220);
imported.value = users.length;
phase.value = 'done';
}
</script>
Spin while the number does not exist, and fill a bar from the moment it does.
BbProgress has no indeterminate mode to fall back on: a null value renders
an empty bar, not an animation.