Use it for
Use BbProgress when you can measure how far along a task is: bytes uploaded,
steps completed, gigabytes of a quota consumed.
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.
Track measurable work
Bind the value you already own and let max do the arithmetic in the task's
real units.
- Q3 board deck.pdf8 MB · Done
- Warehouse photos.zip18.8 MB of 49.6 MB
- Catalogue export 2026-08.csvWaiting
- Supplier contract (signed).pdfUpload failed
<template>
<ul class="flex max-w-md flex-col gap-4 text-sm">
<li
v-for="upload in uploads"
:key="upload.name"
class="flex flex-col gap-1.5"
>
<div class="flex items-center gap-2">
<BbIcon class="shrink-0 opacity-60" icon="lucide:file" size="sm" />
<span class="min-w-0 flex-1 truncate">{{ upload.name }}</span>
<span
class="shrink-0 text-xs tabular-nums"
:class="
upload.failed
? 'text-[color:var(--bb-danger)]'
: 'text-[color:var(--bb-text-muted)]'
"
>
{{ caption(upload) }}
</span>
</div>
<!-- Bytes go in as bytes; `max` is the file size. A failed upload
keeps its bar where it stopped and turns it to the danger token. -->
<BbProgress
:aria-valuetext="caption(upload)"
:label="`Uploading ${upload.name}`"
:max="upload.size"
:model-value="upload.sent"
:style="upload.failed ? { '--accent': 'var(--bb-danger)' } : undefined"
/>
</li>
</ul>
</template>
<script setup lang="ts">
import { BbIcon, BbProgress } from 'bitboss-ui';
import { formatFileSize } from '~/demo-data';
interface Upload {
name: string;
size: number;
sent: number;
failed?: boolean;
}
const uploads: Upload[] = [
{ name: 'Q3 board deck.pdf', size: 8_400_000, sent: 8_400_000 },
{ name: 'Warehouse photos.zip', size: 52_000_000, sent: 19_700_000 },
{ name: 'Catalogue export 2026-08.csv', size: 640_000, sent: 0 },
{
name: 'Supplier contract (signed).pdf',
size: 2_100_000,
sent: 900_000,
failed: true,
},
];
const caption = (upload: Upload) => {
if (upload.failed) return 'Upload failed';
if (upload.sent === 0) return 'Waiting';
if (upload.sent >= upload.size)
return `${formatFileSize(upload.size)} · Done`;
return `${formatFileSize(upload.sent)} of ${formatFileSize(upload.size)}`;
};
</script>
The bar is display-only: it emits no events, and v-model writes nothing back.
Your own units
Megabytes, euros and steps all work as they are, so there is no percentage to compute first.
min moves the lower bound for a range that does not start at zero: a sales
floor, an XP band. Leave it at zero on such a range and the bar still looks
reasonable while showing the wrong fraction. Values outside the range from min to max are clamped.
<template>
<div class="flex max-w-sm flex-col gap-2">
<div class="flex items-baseline justify-between gap-3 text-sm">
<span>Q3 revenue</span>
<span class="tabular-nums text-[color:var(--bb-text-muted)]">
€{{ current }}k
</span>
</div>
<!--
The band runs from the floor, not from zero: €310k is halfway between
€250k and €370k, so the bar is half full — not 84% full.
-->
<BbProgress
:aria-valuetext="`${current} thousand euro, in a band from ${floor} to ${target}`"
label="Q3 revenue against target"
:max="target"
:min="floor"
:model-value="current"
/>
<span class="text-xs text-[color:var(--bb-text-muted)]">
Floor €{{ floor }}k · target €{{ target }}k
</span>
</div>
</template>
<script setup lang="ts">
import { BbProgress } from 'bitboss-ui';
const floor = 250;
const target = 370;
const current = 310;
</script>
A bar that moves
The bar follows your state and never drives it, animating each change over 250 ms. Do not add a transition of your own.
<template>
<div class="flex max-w-sm flex-col gap-3">
<div class="flex items-baseline justify-between gap-3 text-sm">
<span>{{ finished ? 'Workspace set up' : steps[step] }}</span>
<span class="tabular-nums text-(--bb-text-muted)">
{{ done }} of {{ steps.length }} done
</span>
</div>
<!--
The value is the number of completed steps against `max`, so the bar
fills only when a step is actually done and no percentage is computed
anywhere. Each advance animates over the built-in 250 ms transition.
-->
<BbProgress
:aria-valuetext="`${done} of ${steps.length} steps done`"
label="Workspace setup"
:max="steps.length"
:model-value="done"
/>
<!-- Completion is derived from the same state and announced by us. The
live region stays mounted and empty until there is something to say. -->
<div class="flex items-center">
<p class="m-0 text-sm" role="status">{{ finished ? 'Workspace ready.' : '' }}</p>
<template v-if="finished">
<BbButton class="ml-auto" size="sm" variant="ghost" @click="restart">
Start over
</BbButton>
</template>
<template v-else>
<BbButton :disabled="step === 0" variant="ghost" @click="step -= 1">
Back
</BbButton>
<BbButton
v-if="isLast"
class="ml-auto"
prepend:icon="lucide:check"
@click="finished = true"
>
Create project
</BbButton>
<BbButton
v-else
append:icon="lucide:arrow-right"
class="ml-auto"
@click="step += 1"
>
Continue
</BbButton>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbProgress } from 'bitboss-ui';
const steps = [
'Create workspace',
'Invite teammates',
'Connect billing',
'Create first project',
];
// The value moves only when the reader clicks. No timer, no clock.
const step = ref(0);
const finished = ref(false);
const isLast = computed(() => step.value === steps.length - 1);
const done = computed(() => (finished.value ? steps.length : step.value));
function restart() {
finished.value = false;
step.value = 0;
}
</script>
There are no events, so derive completion from the same state that feeds the bar, and announce it yourself: a bar reaching its end is silent.
In a real upload the value comes from your request layer. BbDropzone captures
and validates the files but does not send them, so the request and the progress
it reports stay yours.
const sentBytes = ref(0);
await axios.post('/imports', form, {
onUploadProgress: (event) => (sentBytes.value = event.loaded),
});
When there is no number
Set indeterminate while the wait has no measurable end: the bar slides
and announces no value, and any aria-valuetext you set waits until it turns off.
<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">catalogue-2026-08.csv</span>
<span class="text-xs tabular-nums text-[color:var(--bb-text-muted)]">
{{ statusLabel }}
</span>
</div>
<!-- Fired and forgotten, so the button does not spin beside the bar:
one wait, one indicator. -->
<BbButton
prepend:icon="lucide:upload"
size="sm"
variant="outline"
@click="() => void run()"
>
{{ phase === 'done' ? 'Upload again' : 'Upload' }}
</BbButton>
</div>
<!-- Bytes are countable: the bar, in real kilobytes. Once they are all
sent the server parses rows and reports nothing, so the same bar
slides instead of creeping toward 99%. -->
<BbProgress
v-if="phase !== 'done'"
:aria-valuetext="`${sentKb} of ${totalKb} kilobytes sent`"
:indeterminate="phase === 'processing'"
:label="
phase === 'processing'
? 'Processing catalogue-2026-08.csv'
: 'Uploading catalogue-2026-08.csv'
"
:max="totalKb"
:model-value="sentKb"
/>
<p v-else class="text-sm">Catalogue updated.</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbProgress } from 'bitboss-ui';
import { delay } from '~/demo-data';
type Phase = 'idle' | 'uploading' | 'processing' | 'done';
const totalKb = 640;
const chunkKb = 80;
const phase = ref<Phase>('idle');
const sentKb = ref(0);
const statusLabel = computed(() => {
if (phase.value === 'uploading') return `${sentKb.value} / ${totalKb} KB`;
if (phase.value === 'processing') return 'Processing rows…';
if (phase.value === 'done') return 'Done';
return `${totalKb} KB`;
});
/** Started by the click, advanced by awaited delays — never by a page-load timer. */
async function run() {
if (phase.value === 'uploading' || phase.value === 'processing') return;
phase.value = 'uploading';
sentKb.value = 0;
while (sentKb.value < totalKb) {
await delay(null, 180);
sentKb.value = Math.min(totalKb, sentKb.value + chunkKb);
}
phase.value = 'processing';
await delay(null, 1200);
phase.value = 'done';
}
</script>
The boundary worth handling is the end of an upload, where the bytes stop and
the work does not. Switch the same bar to indeterminate there rather than
creeping it toward 99%. A model-value of null alone is an empty bar,
not a moving one.
Naming and announcing
The bar already carries role="progressbar" and its values, so do not repeat
the role on a wrapper. label becomes the aria-label; a bar with no
label, aria-label or aria-labelledby is an error for npx bitboss-ui check
and the ESLint rule require-accessible-name.
Print the number beside the bar: geometry alone is not a readable value.
aria-valuetext gives screen readers the unit a raw number lacks.
<BbProgress
aria-valuetext="220 of 500 gigabytes"
label="Storage used"
:max="500"
:model-value="220"
/>
Track and fill
There are no size or colour props. Four locals do the job, the same words as
BbSlider's rail and bar: --track (the height, 4px), --track-color (the
unfilled track, var(--bb-track)), --accent (the bar, var(--bb-primary))
and --r (the radius of both, var(--bb-radius)).
<template>
<div class="flex max-w-sm flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-sm">Onboarding · 45%</span>
<!-- `--track` is the height, `--r` rounds the track and the bar
together: a chunkier pill without rebuilding it out of divs. -->
<BbProgress
label="Onboarding progress"
:model-value="45"
class="[--r:9999px] [--track:8px]"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm tabular-nums">Storage · {{ usedGb }} / 50 GB</span>
<!-- Past the soft cap `--accent` (the bar) and `--track-color` (the
unfilled track) switch to the danger token — never a hard-coded hex. -->
<BbProgress
:aria-valuetext="`${usedGb} of 50 gigabytes`"
label="Storage used"
:max="50"
:model-value="usedGb"
:class="
nearLimit
? '[--accent:var(--bb-danger)] [--track-color:color-mix(in_oklab,var(--bb-danger)_18%,transparent)]'
: ''
"
/>
<span class="text-xs text-(--bb-text-muted)">
{{ nearLimit ? 'Nearly full' : 'Plenty of room' }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { BbProgress } from 'bitboss-ui';
const usedGb = 46;
const nearLimit = usedGb / 50 >= 0.8;
</script>
Point --accent at a token rather than a hex, so a retinted bar still follows the
theme.