GrProgressBar
Shows the progress of a task or a loading process.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the share of what is done is known — an import, a file upload, a step of a wizard: the bar answers “how much is left”;
- the end is unknown —
indeterminateshows that the process is running without promising a deadline; - more has been loaded than played —
bufferdraws a second layer: video buffering, prefetching; - the value has to be shown in words —
showValuewithformatValueinstead of a separate caption.
When to take something else
| Need | Take |
|---|---|
| There is no room across the width | GrProgressCircle |
| The share is unknown and the content is already there | GrLoading |
| There is no content yet | GrSkeleton |
| The progress of a file upload | GrFileUpload |
| Show a share of a whole as data | GrChartPie |
When the progress is unknown
<GrProgressBar :value="percent" :indeterminate="!sizeKnown" aria-label="Import" />
indeterminate beats value: the bar runs, and aria-valuenow and aria-valuetext are not set
at all — by the specification that is exactly the sign of indeterminacy, and a separate
aria-busy is not needed. The label of the value is not rendered in this mode: there is nothing
to show.
This is the most frequent progress scenario — the request has left and the server did not report
the size of the answer. GrFileUpload switches the mode on itself when the XHR has no
lengthComputable.
Under prefers-reduced-motion: reduce the bar does not freeze but becomes a neutral fill across
the full width (--gr-progress-indeterminate-bg). A frozen frame of a running bar would lie at
the left edge and read as “40% progress”, that is, it would lie about the state; a fill across the
full width says “work is going on, the value is unknown” and pretends to be neither zero nor
completion. The period of a run is --gr-progress-indeterminate-duration. The general contract of
movement — motion.md.
The label of the value
<GrProgressBar :value="percent" show-value aria-label="Loading" />
<GrProgressBar :value="percent" :format-value="v => `${gb(v)} of 32 GB`" show-value />
showValue prints whole percentages to the right of the track. The label is of a fixed width and
uses tabular figures: the move from 9% to 10% must not jerk the track.
formatValue governs both the label and aria-valuetext — “184 of 512 MB” instead of a bare
“36”. Without a format of your own aria-valuetext is not set: a screen reader already reads “36”
with aria-valuemax="100" as a percentage, and there is no point duplicating that with text. The
format works without showValue too — then it remains for the screen reader alone.
The buffer
<GrProgressBar :value="played" :buffer="buffered" aria-label="Playback" />
A second layer behind the fill: played against buffered, filled against confirmed by the server.
It is clamped by the same rules as value (0..100, NaN → 0) and is not obliged to be larger
than the value — the layer will simply turn out shorter than the fill.
The buffer does not inherit tone and is coloured with a single --gr-progress-buffer-bg: not
all eight tones have -light roles, and a neutral layer between the track and the fill reads
well with any of them.
A value out of range
value and buffer are clamped to 0..100, and any non-numeric value turns into 0. A bar
given -5 or 140 does not break the layout and does not give away an invalid aria-valuenow —
that is a deliberate boundary rather than a side effect.
A mandatory value that did not arrive falls here as well: the bar is drawn empty,
aria-valuenow="0", and in dev mode the component prints a warning. Staying silent here is not
allowed — a screen reader reads aria-valuenow="NaN", and that is an axe violation.
Styling
size is xs…lg and is read from GrConfigProvider; for a linear bar that is the thickness of
the track and the type size of the label, while the width is set by the container. The tone of the
fill is tone from the palette of the package.
borderless removes the border of the track and is likewise read from GrConfigProvider. The
border is needed on a bare background, where the --gr-muted track is barely distinguishable from
the page; inside a card it becomes a second border next to its own.
Pointed customisation is done with the --gr-progress-bg variables (and one per tone),
--gr-progress-buffer-bg, --gr-progress-indeterminate-bg and
--gr-progress-indeterminate-duration. The full list — tokens.md.
The accessible name
role="progressbar" stands on the track, and ariaLabel is the only way to give the bar a name: a
neighbouring heading does not count as a name. Inside GrFileUpload the label is substituted by
the uploader itself.
Playground 8
Loading…
<GrProgressBar />Install
npm i @feugene/granularityImport
import { GrProgressBar } from '@feugene/granularity/components/GrProgressBar'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | "primary" | The colour tone of the fill. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The thickness of the track. |
ariaLabel | string | undefined | undefined | The label for a screen reader (mandatory if there is no visible heading beside it). |
indeterminate | boolean | undefined | false | The progress is unknown: the bar runs, and no value is announced outside. |
borderless | boolean | undefined | undefined | Remove the border of the track: inside a card a second border is only noise. |
buffer | number | undefined | undefined | Loaded ahead: a layer behind the fill, `0..100`. Unset — there is no layer. |
showValue | boolean | undefined | false | Show the value as a label to the right of the track. |
formatValue | ((value: number) => string) | undefined | undefined | A format of your own for the value. It governs both the label and `aria-valuetext`. |
valuerequired | number | — | The current value `0..100`; values out of range are clamped, and a non-numeric one becomes `0`. |
Examples 7
Interactive determinate progress
<script setup lang="ts">
import { ref } from 'vue'
import { GR_TONES, GrButton, GrProgressBar, type GrTone } from '@feugene/granularity'
const progress = ref(32)
const tone = ref<GrTone>('primary')
const tones = GR_TONES
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap gap-2">
<GrButton size="sm" variant="outline" @click="progress = Math.max(0, progress - 16)">
-16%
</GrButton>
<GrButton size="sm" @click="progress = Math.min(100, progress + 16)">
+16%
</GrButton>
</div>
<div class="flex flex-wrap gap-2">
<GrButton
v-for="item in tones"
:key="item"
size="sm"
variant="outline"
:tone="item"
@click="tone = item"
>
{{ item }}
</GrButton>
</div>
<div class="grid gap-2">
<div class="flex items-center justify-between text-sm">
<span>Verification progress</span>
<span class="text-[var(--gr-muted-fg)]">{{ progress }}% · {{ tone }}</span>
</div>
<GrProgressBar :value="progress" :tone="tone" aria-label="Verification progress" />
</div>
</div>
</template>Borderless
<script setup lang="ts">
import { GrCard, GrProgressBar } from '@feugene/granularity'
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2">
<GrCard padding="md" body-class="grid gap-2">
<div class="text-sm font-600">
С рамкой
</div>
<GrProgressBar :value="64" show-value aria-label="Bordered progress" />
<div class="text-xs text-[var(--gr-muted-fg)]">
Дефолт: трек обведён `--gr-brd` и виден на любом фоне.
</div>
</GrCard>
<GrCard padding="md" body-class="grid gap-2">
<div class="text-sm font-600">
borderless
</div>
<GrProgressBar :value="64" borderless show-value aria-label="Borderless progress" />
<div class="text-xs text-[var(--gr-muted-fg)]">
Внутри карточки рамка трека становится второй рамкой рядом с её собственной.
</div>
</GrCard>
</div>
</template>Out-of-range inputs are clamped
<script setup lang="ts">
import { GrBadge, GrProgressBar } from '@feugene/granularity'
const rows = [
{ label: 'Imported from legacy job', raw: -18, tone: 'danger' as const },
{ label: 'Actual processed records', raw: 58, tone: 'info' as const },
{ label: 'Overreported upstream value', raw: 146, tone: 'warning' as const },
]
</script>
<template>
<div class="grid gap-3">
<div
v-for="row in rows"
:key="row.label"
class="grid gap-2 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-3"
>
<div class="flex items-center justify-between gap-3 text-sm">
<span>{{ row.label }}</span>
<GrBadge size="sm" :tone="row.tone">input: {{ row.raw }}%</GrBadge>
</div>
<GrProgressBar :value="row.raw" :tone="row.tone" :aria-label="row.label" />
</div>
</div>
</template>Indeterminate
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { GrButton, GrProgressBar } from '@feugene/granularity'
const known = ref(false)
const progress = ref(0)
let timer: ReturnType<typeof setInterval> | undefined
function start() {
known.value = false
progress.value = 0
clearInterval(timer)
timer = setInterval(() => {
if (progress.value >= 100) {
clearInterval(timer)
return
}
// Ответ сервера пришёл — с этого момента размер известен, и полоса
// перестаёт быть неопределённой.
known.value = true
progress.value = Math.min(100, progress.value + 7)
}, 400)
}
onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap gap-2">
<GrButton size="sm" @click="start">
Запустить запрос
</GrButton>
<GrButton size="sm" variant="outline" @click="known = !known">
{{ known ? 'Прогресс неизвестен' : 'Прогресс известен' }}
</GrButton>
</div>
<div class="grid gap-2">
<div class="text-sm">
{{ known ? 'Загрузка идёт, размер известен' : 'Запрос отправлен, размер ответа неизвестен' }}
</div>
<GrProgressBar
:value="progress"
:indeterminate="!known"
show-value
aria-label="Import progress"
/>
</div>
</div>
</template>Stack of workflow stages
<script setup lang="ts">
import { GrBadge, GrProgressBar } from '@feugene/granularity'
const stages = [
{ label: 'Validation', value: 100, tone: 'success' as const },
{ label: 'Fraud screening', value: 72, tone: 'warning' as const },
{ label: 'Settlement', value: 41, tone: 'neutral' as const },
]
</script>
<template>
<div class="grid gap-3">
<div
v-for="stage in stages"
:key="stage.label"
class="grid gap-2 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-3"
>
<div class="flex items-center justify-between gap-3 text-sm">
<span>{{ stage.label }}</span>
<GrBadge size="sm" :tone="stage.tone">{{ stage.value }}%</GrBadge>
</div>
<GrProgressBar :value="stage.value" :tone="stage.tone" :aria-label="stage.label" />
</div>
</div>
</template>Sizes
<script setup lang="ts">
import { GrProgressBar } from '@feugene/granularity'
const sizes = ['xs', 'sm', 'md', 'lg'] as const
</script>
<template>
<div class="grid gap-4">
<div v-for="size in sizes" :key="size" class="grid gap-2">
<div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
size="{{ size }}"
</div>
<GrProgressBar :value="62" :size="size" aria-label="Upload progress" />
</div>
</div>
</template>Value Buffer
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrButton, GrProgressBar } from '@feugene/granularity'
const played = ref(28)
const buffered = computed(() => Math.min(100, played.value + 24))
const uploadedMb = ref(184)
const totalMb = 512
const uploadPercent = computed(() => (uploadedMb.value / totalMb) * 100)
function formatMb(value: number) {
return `${Math.round((value / 100) * totalMb)} / ${totalMb} МБ`
}
</script>
<template>
<div class="grid gap-5">
<div class="grid gap-2">
<div class="text-sm">
Плеер: заливка — воспроизведено, слой позади — загружено в буфер
</div>
<GrProgressBar
:value="played"
:buffer="buffered"
show-value
aria-label="Playback progress"
/>
<div class="flex flex-wrap gap-2">
<GrButton size="sm" variant="outline" @click="played = Math.max(0, played - 10)">
-10%
</GrButton>
<GrButton size="sm" @click="played = Math.min(100, played + 10)">
+10%
</GrButton>
</div>
</div>
<div class="grid gap-2">
<div class="text-sm">
Своя подпись: `formatValue` управляет и текстом, и `aria-valuetext`
</div>
<GrProgressBar
:value="uploadPercent"
:format-value="formatMb"
show-value
tone="success"
aria-label="Upload progress"
/>
<div class="flex flex-wrap gap-2">
<GrButton size="sm" variant="outline" @click="uploadedMb = Math.max(0, uploadedMb - 64)">
-64 МБ
</GrButton>
<GrButton size="sm" tone="success" @click="uploadedMb = Math.min(totalMb, uploadedMb + 64)">
+64 МБ
</GrButton>
</div>
</div>
</div>
</template>