Skip to content

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) and circular
  • 🌈 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 title on 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" with aria-valuenow, aria-valuemin, aria-valuemax, and aria-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
ts
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

html
<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).

PreviewCode
RTL

Indeterminate

Use indeterminate when the duration of an operation is unknown. The bar animates continuously.

PreviewCode
RTL

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.

PreviewCode
RTL

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.

PreviewCode
RTL

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.

PreviewCode
RTL

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

PreviewCode
RTL

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.

PreviewCode
RTL

Indeterminate

PreviewCode
RTL

Colors

PreviewCode
RTL

Sizes

Three sizes that affect bar height (linear) or ring diameter (circular). Default circular diameter is 6rem.

PreviewCode
RTL
PreviewCode
RTL

Custom Max Value

The default max is 100. Use max to track different units (e.g. steps, bytes).

PreviewCode
RTL

Dynamic Updates

Update value programmatically to reflect real progress.

js
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

AttributeTypeDefaultDescription
valuenumber0Current progress value (0 to max). Ignored when indeterminate.
maxnumber100Maximum value
indeterminatebooleanfalseShow 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)
labelstringVisible 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.
titlestringLinear: header text above the bar; moves label to the header row when combined. Circular: smaller text below the label inside the ring.
floating-labelstringText for the floating chip above the fill endpoint (linear only). Hidden when indeterminate.
value-textstringHuman-readable value for screen readers (e.g. "Step 2 of 5"). Overrides the raw aria-valuenow.

CSS Custom Properties

PropertyDescriptionDefault
--progress-heightLinear bar height overrideSize-dependent
--progress-track-bgTrack (unfilled background) color--color-contrast-200
--progress-fillFill bar / stroke colorTheme-dependent
--progress-radiusLinear bar border radiusvar(--rounded-full)
--progress-circle-sizeCircular ring diameter6rem (size-dependent)
--progress-stroke-widthCircular stroke widthHeight-dependent
--progress-circular-label-sizeFont size of the label inside the ring--text-xl (size-dependent)
--progress-circular-title-sizeFont size of the title inside the ring--text-xs (size-dependent)
--progress-label-gapGap between header/bar row and between bar and trailing label0.25rem
--progress-title-colorTitle text colorcurrentColor
--progress-label-colorLabel text colorcurrentColor

Accessibility

The progress component follows WAI-ARIA best practices.

bit-progress

Screen Readers

  • role="progressbar" is applied to the track element.
  • aria-valuenow reflects the current value (omitted when indeterminate).
  • aria-valuemin is always 0; aria-valuemax reflects max.
  • aria-label resolves in priority order: labeltitle"Progress". Set label to a meaningful description like "Uploading file — 45%".
  • aria-valuetext can be set via value-text for a human-readable override (e.g. "Step 3 of 10").
  • The inner .circular-inner overlay (label + title) is positioned with position: absolute; inset: 0 so the SVG ring renders independently; text never spins even when indeterminate is active.
  • Animations respect prefers-reduced-motion: the sliding/spinning animation is disabled and a static representation is shown.

Best Practices

Do:

  • Use title + label together to build a self-contained progress widget — the title names the operation and the label shows the current value.
  • Use floating-label to surface the exact value visually without breaking layout (linear only).
  • For circular, combine label (value like "75%") with title (context like "Storage") for a self-contained widget.
  • Use circular for dashboard metrics, profile completions, or storage indicators where the ring itself communicates the proportion.
  • Use semantic color to reinforce state: color="success" when complete, color="error" on failure.

Don't:

  • Omit label or title when context is not clear from surrounding text — screen readers need a meaningful aria-label.
  • Use floating-label with indeterminate — 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.