Progress
Reports how far a task has got, whether or not its length is known.
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.
<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 anaria-label, or pointaria-labelledbyat the element that names it. - The percentage, the formatted string and
aria-valuenowall report the clamped value, so a source that overshootsmaxfills the track and says so. The rawvaluestays available on the slot and tooptions.getAriaValueText. options.formatresolves against the runtime locale unlessoptions.localesays otherwise. If the server and the client can differ, pin the locale, or pin the digits withminimumFractionDigitsandmaximumFractionDigits.
Anatomy
Assemble the parts, in whatever order the layout needs.
| Name | Required | Description |
|---|---|---|
| Progress.Root | true | Renders a <div role="progressbar">. Owns the value and writes the percentage. |
| Progress.Label | Renders a <span> whose id becomes the root's aria-labelledby. | |
| Progress.Track | Renders a <div>. Structural: the full length of the bar. | |
| Progress.Indicator | Renders a <div>, sized to the current percentage. | |
| Progress.Value | Renders 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
Options
Emits
None. Progress is a readout.
Slot props
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
Composable
useMirrorProgress(id) reaches a progress from anywhere in the app.
Data attributes
Every part writes the same values, so any of them can be styled from the state without a wrapper class.
CSS variables
Errors
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.