Progress
A progress indicator for conveying operation completion. Supports a classic linear bar and a circular spinner, both with determinate (known value) and indeterminate (unknown duration) modes.
For loading placeholders before progress can be determined, see Skeleton.
Features
- 📊 2 Types:
linear(default) andcircular - 🌈 6 Color Themes: primary, secondary, info, success, warning, error
- 📏 3 Sizes: sm, md, lg
- 🔄 Indeterminate Mode: animated sliding bar or spinning circle when progress is unknown
- 🏷️ Label: visible trailing text (linear) or large text centered inside the ring (circular); moves to a header row when combined with
titleon linear - 📌 Title: header text above the bar (linear) or smaller text below the label inside the ring (circular)
- 💬 Floating Label: chip anchored above the fill endpoint — moves as value changes (linear only)
- ♿ Accessible:
role="progressbar"witharia-valuenow,aria-valuemin,aria-valuemax, andaria-label - 🎨 Customizable: CSS custom properties for colors, size, and border radius
- 🎭 Reduced Motion: animations are disabled automatically for users who prefer reduced motion
Source Code
View Source Code
import { computed, defineComponent, html, watch } from '@vielzeug/craftit';
import type { ComponentSize, ThemeColor } from '../../types';
import { colorThemeMixin, forcedColorsMixin, reducedMotionMixin } from '../../styles';
import componentStyles from './progress.css?inline';
/** Progress bar component properties */
export type BitProgressProps = {
/** Theme color for the fill bar */
color?: ThemeColor;
/** Floating chip centered above the fill endpoint (linear only). Hidden in indeterminate mode. Position formula: left = fill% − half chip width (CSS: left:X%; transform:translateX(−50%)). */
'floating-label'?: string;
/** When true, shows an infinite animation — use when progress is unknown. */
indeterminate?: boolean;
/** Accessible name AND visible text label.
* - Linear without `title`: rendered at the end of the bar.
* - Linear with `title`: moved into the header row above the bar.
* - Circular: large text centered inside the ring. */
label?: string;
/** Maximum value. Defaults to 100. */
max?: number;
/** Size variant controlling bar height */
size?: ComponentSize;
/** Title text.
* - Linear: displayed as a header above the bar; moves `label` into the header row.
* - Circular: smaller text displayed below the `label` inside the ring. */
title?: string;
/** 'linear' (default) or 'circular' */
type?: 'linear' | 'circular';
/** Current progress value (0 to `max`). Ignored when `indeterminate`. */
value?: number;
/** Human-readable value text for screen readers (e.g. "Step 2 of 5", "75%"). Overrides the raw aria-valuenow when set. */
'value-text'?: string;
};
/**
* A linear progress bar for conveying operation progress.
* Supports determinate (known value) and indeterminate (unknown duration) modes.
*
* @element bit-progress
*
* @attr {number} value - Current value (0–max). Defaults to 0.
* @attr {number} max - Maximum value. Defaults to 100.
* @attr {boolean} indeterminate - Show infinite animation (ignores value/max).
* @attr {string} color - Theme color: 'primary' | 'success' | 'warning' | 'error' | …
* @attr {string} size - Bar height: 'sm' | 'md' | 'lg'
* @attr {string} label - Visible text label + accessible name. Linear: at bar end (or header row with title). Circular: large text centered inside the ring.
* @attr {string} title - Title text. Linear: header above the bar (moves label to header row). Circular: smaller text below the label inside the ring.
* @attr {string} floating-label - Floating chip centered above the fill endpoint (linear only); hidden when indeterminate.
*
* @cssprop --progress-height - Bar height override
* @cssprop --progress-track-bg - Track background color
* @cssprop --progress-fill - Fill bar color
* @cssprop --progress-radius - Border radius
* @cssprop --progress-label-gap - Gap between header/bar row and between bar and trailing label (default 0.25 rem)
* @cssprop --progress-title-color - Title text color (defaults to currentColor)
* @cssprop --progress-label-color - Label text color (defaults to currentColor)
* @cssprop --progress-circle-size - Circular indicator diameter (default 6rem)
* @cssprop --progress-circular-label-size - Font size of the label inside the ring (default --text-xl)
* @cssprop --progress-circular-title-size - Font size of the title inside the ring (default --text-xs)
*
* @example
* ```html
* <bit-progress value="45"></bit-progress>
* <bit-progress value="75" max="100" color="success" size="lg"></bit-progress>
* <bit-progress indeterminate color="primary" label="Loading…"></bit-progress>
* ```
*/
export const PROGRESS_TAG = defineComponent<BitProgressProps>({
props: {
color: { default: undefined },
'floating-label': { default: undefined },
indeterminate: { default: false },
label: { default: undefined },
max: { default: 100 },
size: { default: undefined },
title: { default: undefined },
type: { default: 'linear' },
value: { default: 0 },
'value-text': { default: undefined },
},
setup({ host, props }) {
// The SVG circle circumference for a radius of 45 (viewBox 0 0 100 100)
const RADIUS = 45;
const CIRC = 2 * Math.PI * RADIUS; // ~282.7
const percent = computed(() => {
const v = Math.max(0, Math.min(Number(props.value.value), Number(props.max.value)));
const m = Math.max(1, Number(props.max.value));
return `${(v / m) * 100}%`;
});
const dashoffset = computed(() => {
const v = Math.max(0, Math.min(Number(props.value.value), Number(props.max.value)));
const m = Math.max(1, Number(props.max.value));
return CIRC - (v / m) * CIRC;
});
const isCircular = computed(() => props.type.value === 'circular');
// Use watch([...], fn, { immediate: true }) at setup-level so it fires during
// connectedCallback (when attributes are already synced) rather than deferring
// to onMount. The immediate flag triggers the first run synchronously.
watch(
[props.value, props.max, props.indeterminate],
() => {
host.style.setProperty('--_percent', props.indeterminate.value ? '0%' : percent.value);
},
{ immediate: true },
);
return html`
${() =>
isCircular.value
? html` <div
class="circular-track"
role="progressbar"
:aria-valuenow="${() => (props.indeterminate.value ? null : String(props.value.value))}"
aria-valuemin="0"
:aria-valuemax="${() => String(props.max.value)}"
:aria-label="${() => props.label.value ?? props.title.value ?? 'Progress'}"
:aria-valuetext="${() => props['value-text'].value ?? null}"
:style="${() => `--_circ:${CIRC}px`}">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle class="circle-bg" cx="50" cy="50" r="${RADIUS}"></circle>
<circle
class="circle-fill"
cx="50"
cy="50"
r="${RADIUS}"
:stroke-dasharray="${() => (props.indeterminate.value ? undefined : `${CIRC}px`)}"
:stroke-dashoffset="${() =>
props.indeterminate.value ? undefined : `${dashoffset.value}px`}"></circle>
</svg>
<div class="circular-inner">
<span class="circular-label">${() => props.label.value}</span>
<span class="circular-title">${() => props.title.value}</span>
</div>
</div>`
: html` <div class="wrapper">
<div class="header">
<span class="progress-title">${() => props.title.value}</span>
<span class="end-label header-label">${() => props.label.value}</span>
</div>
<div class="bar-row">
<div class="track-outer">
<div
class="track"
role="progressbar"
:aria-valuenow="${() => (props.indeterminate.value ? null : String(props.value.value))}"
aria-valuemin="0"
:aria-valuemax="${() => String(props.max.value)}"
:aria-label="${() => props.label.value ?? props.title.value ?? 'Progress'}"
:aria-valuetext="${() => props['value-text'].value ?? null}">
<div
class="fill"
part="fill"
:style="${() => (!props.indeterminate.value ? `width:${percent.value}` : null)}"></div>
</div>
<span class="floating-label">${() => props['floating-label'].value}</span>
</div>
<span class="end-label row-label">${() => props.label.value}</span>
</div>
</div>`}
`;
},
styles: [colorThemeMixin, forcedColorsMixin, reducedMotionMixin, componentStyles],
tag: 'bit-progress',
});Basic Usage
<bit-progress value="45"></bit-progress>
<script type="module">
import '@vielzeug/buildit/progress';
</script>Linear Bar
Determinate
Show a known value between 0 and max (default: 100).
Indeterminate
Use indeterminate when the duration of an operation is unknown. The bar animates continuously.
Label
The label attribute renders visible text and doubles as the accessible aria-label.
Linear:
- Without
title— rendered at the end of the bar (trailing inline). - With
title— moved into the header row above the bar.
Circular: renders as large, bold text centered inside the ring.
Title
The title attribute provides contextual text.
Linear: displayed as a header row above the bar. When both title and label are set, the label moves into the header row right-aligned next to the title.
Circular: displayed as smaller text below the label inside the ring.
Floating Label
The floating-label attribute renders a chip above the fill endpoint, centered on the current progress position. The chip tracks the fill value as it changes. It is automatically hidden when indeterminate is set.
Circular
Set type="circular" to render a circular progress ring. The default diameter is 6rem (sm: 4rem, lg: 9rem) — large enough to display content inside.
Determinate
Label and Title inside the ring
Use label for the primary value text centered inside the ring, and title for a smaller descriptor below it.
Indeterminate
Colors
Sizes
Three sizes that affect bar height (linear) or ring diameter (circular). Default circular diameter is 6rem.
Custom Max Value
The default max is 100. Use max to track different units (e.g. steps, bytes).
Dynamic Updates
Update value programmatically to reflect real progress.
const bar = document.querySelector('bit-progress');
let progress = 0;
const interval = setInterval(() => {
progress += 5;
bar.setAttribute('value', String(progress));
if (progress >= 100) {
clearInterval(interval);
}
}, 200);API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | number | 0 | Current progress value (0 to max). Ignored when indeterminate. |
max | number | 100 | Maximum value |
indeterminate | boolean | false | Show infinite animation when duration is unknown |
type | 'linear' | 'circular' | 'linear' | Bar style |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | — | Theme color for the fill |
size | 'sm' | 'md' | 'lg' | — | Bar height (linear) or circle diameter (circular) |
label | string | — | Visible text label and accessible name. Linear without title: rendered at bar end. Linear with title: moved to the header row. Circular: large text centered inside the ring. Falls back to title, then "Progress" for aria-label. |
title | string | — | Linear: header text above the bar; moves label to the header row when combined. Circular: smaller text below the label inside the ring. |
floating-label | string | — | Text for the floating chip above the fill endpoint (linear only). Hidden when indeterminate. |
value-text | string | — | Human-readable value for screen readers (e.g. "Step 2 of 5"). Overrides the raw aria-valuenow. |
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--progress-height | Linear bar height override | Size-dependent |
--progress-track-bg | Track (unfilled background) color | --color-contrast-200 |
--progress-fill | Fill bar / stroke color | Theme-dependent |
--progress-radius | Linear bar border radius | var(--rounded-full) |
--progress-circle-size | Circular ring diameter | 6rem (size-dependent) |
--progress-stroke-width | Circular stroke width | Height-dependent |
--progress-circular-label-size | Font size of the label inside the ring | --text-xl (size-dependent) |
--progress-circular-title-size | Font size of the title inside the ring | --text-xs (size-dependent) |
--progress-label-gap | Gap between header/bar row and between bar and trailing label | 0.25rem |
--progress-title-color | Title text color | currentColor |
--progress-label-color | Label text color | currentColor |
Accessibility
The progress component follows WAI-ARIA best practices.
bit-progress
✅ Screen Readers
role="progressbar"is applied to the track element.aria-valuenowreflects the currentvalue(omitted whenindeterminate).aria-valueminis always0;aria-valuemaxreflectsmax.aria-labelresolves in priority order:label→title→"Progress". Setlabelto a meaningful description like"Uploading file — 45%".aria-valuetextcan be set viavalue-textfor a human-readable override (e.g."Step 3 of 10").- The inner
.circular-inneroverlay (label + title) is positioned withposition: absolute; inset: 0so the SVG ring renders independently; text never spins even whenindeterminateis active. - Animations respect
prefers-reduced-motion: the sliding/spinning animation is disabled and a static representation is shown.
Best Practices
Do:
- Use
title+labeltogether to build a self-contained progress widget — the title names the operation and the label shows the current value. - Use
floating-labelto surface the exact value visually without breaking layout (linear only). - For circular, combine
label(value like"75%") withtitle(context like"Storage") for a self-contained widget. - Use
circularfor dashboard metrics, profile completions, or storage indicators where the ring itself communicates the proportion. - Use semantic
colorto reinforce state:color="success"when complete,color="error"on failure.
Don't:
- Omit
labelortitlewhen context is not clear from surrounding text — screen readers need a meaningfularia-label. - Use
floating-labelwithindeterminate— the chip is hidden in that state since there is no defined endpoint to pin it to. - Use progress bars for step-based flows — use a stepper component instead.
- Animate the progress bar too quickly or too slowly — aim for real-time updates that reflect actual operation state.