Use it for
Use BbProgress when you can measure how far along a task is: bytes uploaded,
steps completed, gigabytes of a quota consumed.
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Track measurable work
Bind the value you already own and let max do the arithmetic in the task's
real units.
<template>
<div class="flex max-w-sm flex-col gap-2">
<div class="flex items-baseline justify-between gap-3 text-sm">
<span>Storage used</span>
<span class="tabular-nums text-[color:var(--bb-text-muted)]">
{{ usedGb }} / {{ maxGb }} GB
</span>
</div>
<!--
Gigabytes go in as gigabytes. `max` does the arithmetic, and
`aria-valuetext` gives the reading that "42.6" alone would not.
-->
<BbProgress
:aria-valuetext="`${usedGb} of ${maxGb} gigabytes`"
label="Storage used"
:max="maxGb"
:model-value="usedGb"
/>
<span class="text-xs text-[color:var(--bb-text-muted)]">
{{ remainingGb }} GB free on the Team plan
</span>
</div>
</template>
<script setup lang="ts">
import { BbProgress } from 'bitboss-ui';
const usedGb = 42.6;
const maxGb = 50;
const remainingGb = Number((maxGb - usedGb).toFixed(1));
</script>
The bar is display-only: it emits no events, and v-model writes nothing back.
Your own units
Pass the numbers you already hold and let max do the arithmetic, rather than
computing a percentage 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 and the bar is wrong but plausible, and
values outside the range 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>{{ currentLabel }}</span>
<span class="tabular-nums text-[color:var(--bb-text-muted)]">
Step {{ step + 1 }} of {{ steps.length }}
</span>
</div>
<!--
`max` is the number of steps and the value is the 1-based current one,
so no percentage is computed anywhere. Each advance animates over the
built-in 250 ms transition — do not add one of your own.
-->
<BbProgress
:aria-valuetext="`Step ${step + 1} of ${steps.length}`"
label="Workspace setup"
:max="steps.length"
:model-value="step + 1"
/>
<div class="flex items-center justify-between gap-3">
<BbButton :disabled="step === 0" variant="ghost" @click="back">
Back
</BbButton>
<BbButton
append:icon="lucide:arrow-right"
:disabled="step === steps.length - 1"
@click="next"
>
Continue
</BbButton>
</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 currentLabel = computed(() => steps[step.value] ?? '');
function back() {
step.value -= 1;
}
function next() {
step.value += 1;
}
</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
A model-value of null renders an empty bar. There is no indeterminate mode,
because the library already has one and it is BbSpinner.
<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>
<BbButton
prepend:icon="lucide:upload"
size="sm"
variant="outline"
@click="run"
>
{{ phase === 'done' ? 'Upload again' : 'Upload' }}
</BbButton>
</div>
<!-- Bytes are countable: the bar, in real kilobytes. -->
<BbProgress
v-if="phase === 'idle' || phase === 'uploading'"
:aria-valuetext="`${sentKb} of ${totalKb} kilobytes sent`"
label="Uploading catalogue-2026-08.csv"
:max="totalKb"
:model-value="sentKb"
/>
<!-- The server is now parsing rows and reports nothing. Do not creep the
bar toward 99% — swap it for a spinner and say what is happening. -->
<div
v-else-if="phase === 'processing'"
class="flex items-center gap-2 text-sm text-[color:var(--bb-text-muted)]"
role="status"
>
<BbSpinner size="sm" />
Processing rows…
</div>
<p v-else class="text-sm">Catalogue updated.</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbProgress, BbSpinner } 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 'Upload complete';
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() {
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. Swap in the spinner there rather than creeping the bar toward 99%.
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.
Print the number beside the bar: geometry alone is not a readable value.
aria-valuetext gives the reading a raw number does not have out loud.
<BbProgress
aria-valuetext="220 of 500 gigabytes"
label="Storage used"
:max="500"
:model-value="220"
/>
Track and fill
There are no size or color props: --track is the height of the track, 4px by
default, and --fill the color of the filled part, var(--bb-primary).
<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 of the bar. Overriding it is how you get a
chunkier bar without rebuilding the component out of divs. -->
<BbProgress
class="[--track:8px]"
label="Onboarding progress"
:model-value="45"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm tabular-nums">Storage · {{ usedGb }} / 50 GB</span>
<!-- `--fill` is the colour of the filled part. Past the soft cap it
switches to a theme token — never to a hard-coded hex. -->
<BbProgress
:aria-valuetext="`${usedGb} of 50 gigabytes`"
label="Storage used"
:max="50"
:model-value="usedGb"
:style="nearLimit ? { '--fill': 'var(--bb-danger)' } : undefined"
/>
<span class="text-xs text-[color:var(--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 --fill at a token rather than a hex, so a retinted bar still follows the
theme.