GrProgressBar

Package: @feugene/granularitycoreGroup: feedback

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 unknownindeterminate shows that the process is running without promising a deadline;
  • more has been loaded than playedbuffer draws a second layer: video buffering, prefetching;
  • the value has to be shown in wordsshowValue with formatValue instead of a separate caption.

When to take something else

NeedTake
There is no room across the widthGrProgressCircle
The share is unknown and the content is already thereGrLoading
There is no content yetGrSkeleton
The progress of a file uploadGrFileUpload
Show a share of a whole as dataGrChartPie

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, NaN0) 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 xslg 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…

Code
<GrProgressBar />

Install

npm i @feugene/granularity

Import

import { GrProgressBar } from '@feugene/granularity/components/GrProgressBar'

API

Props

PropTypedefaultDescription
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"primary"The colour tone of the fill.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe thickness of the track.
ariaLabelstring | undefinedundefinedThe label for a screen reader (mandatory if there is no visible heading beside it).
indeterminateboolean | undefinedfalseThe progress is unknown: the bar runs, and no value is announced outside.
borderlessboolean | undefinedundefinedRemove the border of the track: inside a card a second border is only noise.
buffernumber | undefinedundefinedLoaded ahead: a layer behind the fill, `0..100`. Unset — there is no layer.
showValueboolean | undefinedfalseShow the value as a label to the right of the track.
formatValue((value: number) => string) | undefinedundefinedA format of your own for the value. It governs both the label and `aria-valuetext`.
valuerequirednumberThe current value `0..100`; values out of range are clamped, and a non-numeric one becomes `0`.

Examples 7

Interactive determinate progress

Verification progress32% · primary

Basic
<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

С рамкой
64%
Дефолт: трек обведён `--gr-brd` и виден на любом фоне.
borderless
64%
Внутри карточки рамка трека становится второй рамкой рядом с её собственной.

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

Imported from legacy jobinput: -18%
Actual processed recordsinput: 58%
Overreported upstream valueinput: 146%

Clamp
<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

Запрос отправлен, размер ответа неизвестен

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

Validation100%
Fraud screening72%
Settlement41%

Pipeline
<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

size="xs"
size="sm"
size="md"
size="lg"

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

Плеер: заливка — воспроизведено, слой позади — загружено в буфер
28%
Своя подпись: `formatValue` управляет и текстом, и `aria-valuetext`
184 / 512 МБ

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>

Component documentationAll components