Skip to content

Progress

Reports how far a task has got, whether or not its length is known.

View source View as Markdown

Progress is a readout of how far a task has got, drawn on screen and announced to a screen reader. Useful whenever something takes long enough that the interface has to say so; leave value unset and it reports a task whose length nobody knows yet.

Uploading
x
Preparing
x
App.vue
<template>
  <div class="flex w-64 flex-col gap-5">
    <Progress.Root
      :value="value"
      aria-label="Uploading"
      class="flex flex-col gap-2"
    >
      <div
        class="type-surface-code text-surface-muted flex items-baseline justify-between"
      >
        <span>Uploading</span>
        <Progress.Value class="tabular-nums" />
      </div>

      <Progress.Track :class="track">
        <Progress.Indicator :class="indicator" />
      </Progress.Track>
    </Progress.Root>

    <Progress.Root
      :value="null"
      aria-label="Preparing"
      class="flex flex-col gap-2"
    >
      <span class="type-surface-code text-surface-muted">Preparing</span>

      <Progress.Track :class="track">
        <div class="bg-primary-solid h-full w-1/3 animate-pulse" />
      </Progress.Track>
    </Progress.Root>

    <div class="flex gap-2">
      <Button :class="button" @click="step(-20)">−20</Button>
      <Button :class="button" @click="step(20)">+20</Button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Button, Progress } from '@maas/mirror/vue'

const value = ref(40)

function step(amount: number) {
  value.value = Math.min(100, Math.max(0, value.value + amount))
}

const track = 'rounded-component-round bg-surface-higher h-2 overflow-hidden'

const indicator = [
  'h-full w-full! origin-left scale-x-[calc(var(--mpr-percentage,0)*1%)]',
  'bg-primary-solid transition-transform duration-300',
  'data-[state=complete]:bg-success-solid',
].join(' ')

const button = [
  'rounded-component-md border-surface border px-2.5 py-1',
  'type-component-xs text-surface-muted hover:text-surface transition-colors',
].join(' ')
</script>

Usage guidelines

  • A progressbar has no implicit name, so render a Progress.Label, give the root an aria-label, or point aria-labelledby at the element that names it.
  • The percentage, the formatted string and aria-valuenow all report the clamped value, so a source that overshoots max fills the track and says so. The raw value stays available on the slot and to options.getAriaValueText.
  • options.format resolves against the runtime locale unless options.locale says otherwise. If the server and the client can differ, pin the locale, or pin the digits with minimumFractionDigits and maximumFractionDigits.

Anatomy

Assemble the parts, in whatever order the layout needs.

NameRequiredDescription
Progress.Root true Renders a <div role="progressbar">. Owns the value and writes the percentage.
Progress.LabelRenders a <span> whose id becomes the root's aria-labelledby.
Progress.TrackRenders a <div>. Structural: the full length of the bar.
Progress.IndicatorRenders a <div>, sized to the current percentage.
Progress.ValueRenders a <span> with the formatted value.
<script setup lang="ts">
import { Progress } from '@maas/mirror/vue'
</script>

<template>
  <Progress.Root :value="uploaded" :options="{ max: total }">
    <Progress.Label>Uploading</Progress.Label>

    <Progress.Track>
      <Progress.Indicator />
    </Progress.Track>

    <Progress.Value />
  </Progress.Root>
</template>

Examples

Determinate progress

Pass a value and the percentage follows from where it sits between min and max.

<template>
  <Progress.Root
    :value="uploaded"
    :options="{ max: total }"
    aria-label="Uploading"
  >
    <Progress.Track>
      <Progress.Indicator />
    </Progress.Track>
  </Progress.Root>
</template>

Naming the progress

Progress.Label renders a <span> and registers its id as the root’s aria-labelledby, so the name lives in the markup rather than in an attribute.

<template>
  <Progress.Root :value="uploaded" :options="{ max: total }">
    <Progress.Label>Uploading</Progress.Label>
    <Progress.Track>
      <Progress.Indicator />
    </Progress.Track>
  </Progress.Root>
</template>

Without it the root points nowhere, so give it an aria-label instead.

Indeterminate progress

Leave value at its default of null and aria-valuenow is dropped, which is how a screen reader is told the progress is indeterminate. A value that is not a finite number, such as the NaN or Infinity an unguarded division returns, reads the same way rather than rendering as NaN.

<template>
  <Progress.Root aria-label="Preparing">
    <Progress.Track>
      <Progress.Indicator />
    </Progress.Track>
  </Progress.Root>
</template>

aria-valuetext says indeterminate progress in that state, and the Progress.Value slot is handed the string indeterminate while the element itself renders nothing.

There is no percentage to size the indicator with, so that state is yours to style.

.indicator[data-state='indeterminate'] {
  inline-size: 33%;
  animation: slide 1.2s linear infinite;
}

Sizing the indicator

Progress.Root writes the percentage to --mpr-percentage, a unitless number between 0 and 100, and the CSS we ship turns it into the indicator’s width.

.mirror-progress-indicator {
  block-size: 100%;
  inline-size: calc(var(--mpr-percentage, 0) * 1%);
}

The property sits on the root, so anything inside the component can read it, whether that is a tick mark, a moving label or a conic-gradient ring.

.ring {
  background: conic-gradient(
    var(--app-color-primary-bg-solid) calc(var(--mpr-percentage, 0) * 1%),
    var(--app-color-surface-bg-higher) 0
  );
}

An indeterminate progress does not set the property at all, which is why every var(--mpr-percentage, …) needs a fallback.

Formatting the value

options.format drives both Progress.Value and aria-valuetext, and defaults to { style: 'percent' }, which formats the percentage rather than the value.

<template>
  <Progress.Root :value="0.42" :options="{ max: 1 }" aria-label="Uploading">
    <Progress.Value />
  </Progress.Root>
</template>

Any other style formats the clamped value instead, which is how you report raw units rather than a share of the whole.

<template>
  <Progress.Root
    :value="bytes"
    :options="{
      max: total,
      format: { style: 'unit', unit: 'megabyte', maximumFractionDigits: 1 },
    }"
    aria-label="Uploading"
  >
    <Progress.Value />
  </Progress.Root>
</template>

Take the slot to put the formatted string in markup of your own.

<template>
  <Progress.Value v-slot="{ formatted, percentage }">
    {{ formatted }} · {{ Math.round(percentage ?? 0) }} of 100
  </Progress.Value>
</template>

options.locale pins the locale Intl.NumberFormat resolves against, which is what keeps a server render and a client render reading the same.

<template>
  <Progress.Root :value="uploaded" :options="{ max: total, locale: 'en-GB' }" />
</template>

Overriding the announcement

options.getAriaValueText receives the formatted string and the raw value, and what it returns replaces aria-valuetext entirely.

<template>
  <Progress.Root
    :value="uploaded"
    :options="{
      max: total,
      getAriaValueText: (formatted, value) => `${value} of ${total} files`,
    }"
    aria-label="Uploading"
  >
    <Progress.Track>
      <Progress.Indicator />
    </Progress.Track>
  </Progress.Root>
</template>

It is called in the indeterminate state too, where value is null and formatted is an empty string, so guard for those. Leave it out and the indeterminate state announces indeterminate progress.

API reference

Module. A bundled options object, and useMirrorProgress(id) as the programmatic API.

Progress.Root

Renders a <div role="progressbar">.

Props

PropTypeDefault
id
stringgenerated
value
number | nullnull
options
ProgressOptionssee below

Options

OptionTypeDefault
min
number0
max
number100
locale
Intl.LocalesArgumentthe runtime locale
format
Intl.NumberFormatOptions{ style: 'percent' }
getAriaValueText
(formatted: string, value: number | null) => stringundefined

Emits

None. Progress is a readout.

Slot props

PropType
value
number | null
percentage
number | null
status
'indeterminate' | 'progressing' | 'complete'
formatted
string

Progress.Label

Renders a <span> and registers its id as the root’s aria-labelledby. No props of its own.

Slot props

status: 'indeterminate' | 'progressing' | 'complete'.

Progress.Track

Renders a <div>, the full length of the bar and the box the indicator is measured against. No props of its own.

Progress.Indicator

Renders a <div>, sized to calc(var(--mpr-percentage, 0) * 1%) on the inline axis and 100% on the block axis. No props of its own.

Progress.Value

Renders a <span> with the formatted value, aria-hidden because the root already announces it. No props of its own.

Slot props

PropType
value
number | null
percentage
number | null
formatted
string

Composable

useMirrorProgress(id) reaches a progress from anywhere in the app.

KeyType
value
ComputedRef<number | null>
percentage
ComputedRef<number | null>
status
ComputedRef<ProgressStatus>
formatted
ComputedRef<string>
setValue
(next: number | null) => void

Data attributes

Every part writes the same values, so any of them can be styled from the state without a wrapper class.

PartAttributeValue
all
data-state
indeterminate | progressing | complete
all
data-scope
root | track | indicator | label | value

CSS variables

PartVariableDefault
Progress.Track
--mirror-progress-track-size
0.5rem

Errors

Code
missing_progress_context
invalid_progress_range

Accessibility

Progress.Root renders a role="progressbar" with aria-valuemin, aria-valuemax, aria-valuenow and aria-valuetext, and takes no focus and no keyboard interaction. A Progress.Label inside it names it through aria-labelledby.

An indeterminate progress omits aria-valuenow and announces indeterminate progress, unless options.getAriaValueText supplies something else.

The root also renders one visually hidden character of text, because NVDA only reads the name of a progressbar that has content.