GrProgressCircle

Package: @feugene/granularitycoreGroup: feedback

Shows progress as a ring or a gauge, with the value in the middle.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • there is no room across the width — a dashboard tile, a cell, a metric card;
  • the value matters more than the course — a number in the centre reads as a metric rather than as a wait;
  • a “speedometer” gauge is neededshape breaks the ring open when that is clearer;
  • the state has to be shown with a signstatusIcon replaces the number with a success or a failure on completion.

When to take something else

NeedTake
There is enough room across the widthGrProgressBar
The share is unknown, the content is already thereGrLoading
A number without a gaugeGrStatistic
The shares of several parts of a wholeGrChartPie

The centre lies beside the role rather than inside it

The markup is deliberately two-layered: role="progressbar" is carried by the SVG alone, and the content of the centre is its absolutely positioned neighbour.

The reason is not cosmetic. A widget role declares its descendants presentational, and a “Cancel” button inside the ring — the very first upload scenario — would turn out nested-interactive: a screen reader would lose both the button and the indicator. The slot of the centre has to remain a place where anything at all can be put, interactive elements included.

The layer of the centre itself does not catch the pointer (pointer-events: none) — otherwise it would cover the ring; an interactive element inside switches the events back on for itself.

The value, the icon, the slot

The priority of the content of the centre: the slot → the outcome icon → the value.

  • showValue prints percentages, and formatValue sets a text of your own along with aria-valuetext;
  • statusIcon replaces the number with a tick at value >= 100 and with a cross at tone="danger" — that is, only in terminal states;
  • the default slot receives the already clamped value and beats both variants. An empty slot (a button under a v-if, for instance) does not occupy the centre — what counts is the content, not the fact that a slot was passed.

The value fits into the centre from sm upwards; at xs it is either not shown or its type size is reduced with the --gr-progress-circle-value-size token.

The shapes

circle is a closed ring, counted from twelve o’clock clockwise. dashboard is three quarters of a circumference with the cut-out strictly at the bottom: room is freed under the value for a caption, and the gauge reads as a speedometer. The value means the same thing in both shapes — the share travelled — and only the length of the track changes.

The tones are shared with the bar

The arc is coloured with the same variables as GrProgressBar (--gr-progress-bg, --gr-progress-success-bg and their neighbours): progress has one theming layer, and a consumer who has recoloured it expects the circle and the bar to agree. Every variable is taken with a fallback to a role of the theme — with a granular import of the circle alone the per-component theme of the bar is not connected, and without the fallback the arc would be left without colour.

Only the geometry belongs to the circle: --gr-progress-circle-size, --gr-progress-circle-track, --gr-progress-circle-cap, --gr-progress-circle-value-size.

Unknown progress

indeterminate sends the arc around the circle and announces no value: aria-valuenow is not set and the label is not printed.

Under prefers-reduced-motion the component shows a closed neutral ring rather than stopping the rotation. A stopped quarter of an arc would read as “25 % progress” — the same argument for which GrProgressBar has a reduce branch of its own rather than the general clamp of animations.

Limits

  • buffer is not carried over from the bar: the “loaded ahead” layer reads on a gauge that is seen from left to right as a whole; on a ring two nested arcs look like one thick one;
  • the size is by the scale of the package only — the pixel escape hatch belongs to GrAvatar and GrIcon exactly; pointed adjustment goes through --gr-progress-circle-size, and the geometry of the arc is computed in viewBox units and does not depend on the diameter;
  • there is no gradient along the arc — it requires a <linearGradient> with an id of its own in every instance, that is, one more source of hydration divergences;
  • there are no segments and no ticks — a ring with divisions is an indicator of steps rather than of progress.

Playground 10

Loading…

Code
<GrProgressCircle />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefinedundefinedThe colour tone of the arc.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe diameter of the ring by the scale of the package.
ariaLabelstring | undefinedundefinedThe label for a screen reader (mandatory if there is no visible heading beside it).
shape"circle" | "dashboard" | undefinedundefinedA closed ring or an arc with a cut-out at the bottom.
valuenumber | undefined0The current value `0..100`; values out of range are clamped, and `NaN` becomes `0`.
indeterminateboolean | undefinedfalseThe progress is unknown: the arc runs around the circle, and no value is announced outside.
thicknessnumber | undefinedundefinedThe thickness of the stroke as a percentage of the diameter. Unset — by the `size` step.
showValueboolean | undefinedfalseShow the value in the centre of the ring. The default slot is stronger.
formatValue((value: number) => string) | undefinedundefinedA format of your own for the value. It governs both the label and `aria-valuetext`.
statusIconboolean | undefinedfalseOn completion — a tick, and with `tone="danger"` — a cross instead of the value.
tracklessboolean | undefinedundefinedRemove the track: on top of a picture the empty part of the ring is only noise.

Slots

SlotTypeDescription
default{ value: number; }The content of the centre instead of the value: an icon, two lines, a cancel button.

Examples 4

Basic

xs
64%
sm
64%
md
64%
lg
72%
48%

Basic
<script setup lang="ts">
import { GrProgressCircle } from '@feugene/granularity'

const sizes = ['xs', 'sm', 'md', 'lg'] as const
const tones = [
  { tone: 'primary', value: 72 },
  { tone: 'success', value: 100 },
  { tone: 'warning', value: 48 },
  { tone: 'danger', value: 19 },
] as const
</script>

<template>
  <div class="grid gap-6">
    <div class="flex flex-wrap items-end gap-6">
      <div v-for="size in sizes" :key="size" class="grid justify-items-center gap-2">
        <GrProgressCircle :value="64" :size="size" :show-value="size !== 'xs'" aria-label="Заполнение диска" />
        <code class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">{{ size }}</code>
      </div>
    </div>

    <div class="flex flex-wrap items-center gap-6">
      <GrProgressCircle
        v-for="item in tones"
        :key="item.tone"
        :value="item.value"
        :tone="item.tone"
        show-value
        status-icon
        :aria-label="`Тон ${item.tone}`"
      />
    </div>
  </div>
</template>

Dashboard

72%
CPU
91%
Память
34%
Диск
58%
Сеть · вживую

Dashboard
<!-- useTweenedValue.ts -->
import { onBeforeUnmount, ref } from 'vue'

/**
 * Значение, которое едет к новой точке за заданное время, а не прыгает.
 *
 * Собственный переход дуги (`--gr-duration-base`) сглаживает только сам скачок:
 * при шаге раз в пять секунд кольцо дёргалось бы за долю секунды и стояло всё
 * остальное время. Здесь движение растягивается на весь интервал — и число в
 * центре едет вместе с дугой.
 */
export function useTweenedValue(initial: number) {
  const value = ref(initial)

  let frame: number | undefined

  function stop(): void {
    if (frame !== undefined)
      cancelAnimationFrame(frame)
    frame = undefined
  }

  /** Уважать «уменьшить движение» обязан тот, кто двигает: CSS-кламп до JS не достаёт. */
  function prefersReducedMotion(): boolean {
    return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
  }

  function tweenTo(target: number, duration: number): void {
    stop()

    const from = value.value
    if (from === target || duration <= 0 || prefersReducedMotion()) {
      value.value = target
      return
    }

    const start = performance.now()

    function step(now: number): void {
      const progress = Math.min(1, (now - start) / duration)
      value.value = from + (target - from) * progress

      if (progress < 1)
        frame = requestAnimationFrame(step)
      else frame = undefined
    }

    frame = requestAnimationFrame(step)
  }

  /** Мгновенно — для разрыва шкалы, где плавный переход выглядел бы перемоткой. */
  function jumpTo(target: number): void {
    stop()
    value.value = target
  }

  onBeforeUnmount(stop)

  return { value, tweenTo, jumpTo }
}

<!-- GrProgressCircleDashboardDemo.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { GrCard, GrProgressCircle } from '@feugene/granularity'

import { useTweenedValue } from './useTweenedValue'

const metrics = [
  { label: 'CPU', value: 72, tone: 'primary' as const },
  { label: 'Память', value: 91, tone: 'warning' as const },
  { label: 'Диск', value: 34, tone: 'success' as const },
]

/** Живая метрика: случайный шаг в пределах ±5 %, но не дальше ±10 % от базы. */
const LIVE_BASE = 58
const STEP = 5
const BAND = 10
const TICK = 1000

const { value: live, tweenTo } = useTweenedValue(LIVE_BASE)

let timer: ReturnType<typeof setInterval> | undefined

function nextValue(current: number): number {
  const delta = (Math.random() * 2 - 1) * STEP

  return Math.min(LIVE_BASE + BAND, Math.max(LIVE_BASE - BAND, current + delta))
}

onMounted(() => {
  // Дрожащая цифра — ровно то, чего не хочет «уменьшить движение».
  if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches)
    return

  timer = setInterval(() => tweenTo(nextValue(live.value), TICK), TICK)
})

onBeforeUnmount(() => clearInterval(timer))
</script>

<template>
  <div class="flex flex-wrap gap-4">
    <GrCard v-for="metric in metrics" :key="metric.label">
      <div class="grid justify-items-center gap-2 px-4 py-2">
        <GrProgressCircle
          :value="metric.value"
          :tone="metric.tone"
          shape="dashboard"
          size="lg"
          show-value
          :aria-label="metric.label"
        />
        <span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">{{ metric.label }}</span>
      </div>
    </GrCard>

    <GrCard>
      <div class="grid justify-items-center gap-2 px-4 py-2">
        <GrProgressCircle
          :value="live"
          tone="info"
          shape="dashboard"
          size="lg"
          show-value
          aria-label="Сеть"
        />
        <span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">Сеть · вживую</span>
      </div>
    </GrCard>
  </div>
</template>

Ticking

35%
+5 % раз в секунду
35%
+5 % раз в пять секунд

Оба кольца прибавляют по 5 % на шаг, но левое делает это раз в секунду, а правое — раз в пять, и каждый шаг растянут на весь интервал до следующего: движение идёт от прежней точки к новой, а не рывком.

Ticking
<!-- useTweenedValue.ts -->
import { onBeforeUnmount, ref } from 'vue'

/**
 * Значение, которое едет к новой точке за заданное время, а не прыгает.
 *
 * Собственный переход дуги (`--gr-duration-base`) сглаживает только сам скачок:
 * при шаге раз в пять секунд кольцо дёргалось бы за долю секунды и стояло всё
 * остальное время. Здесь движение растягивается на весь интервал — и число в
 * центре едет вместе с дугой.
 */
export function useTweenedValue(initial: number) {
  const value = ref(initial)

  let frame: number | undefined

  function stop(): void {
    if (frame !== undefined)
      cancelAnimationFrame(frame)
    frame = undefined
  }

  /** Уважать «уменьшить движение» обязан тот, кто двигает: CSS-кламп до JS не достаёт. */
  function prefersReducedMotion(): boolean {
    return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
  }

  function tweenTo(target: number, duration: number): void {
    stop()

    const from = value.value
    if (from === target || duration <= 0 || prefersReducedMotion()) {
      value.value = target
      return
    }

    const start = performance.now()

    function step(now: number): void {
      const progress = Math.min(1, (now - start) / duration)
      value.value = from + (target - from) * progress

      if (progress < 1)
        frame = requestAnimationFrame(step)
      else frame = undefined
    }

    frame = requestAnimationFrame(step)
  }

  /** Мгновенно — для разрыва шкалы, где плавный переход выглядел бы перемоткой. */
  function jumpTo(target: number): void {
    stop()
    value.value = target
  }

  onBeforeUnmount(stop)

  return { value, tweenTo, jumpTo }
}

<!-- GrProgressCircleTickingDemo.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { GrProgressCircle } from '@feugene/granularity'

import { useTweenedValue } from './useTweenedValue'

const STEP = 5
const START = 35

const { value: fast, tweenTo: tweenFast, jumpTo: jumpFast } = useTweenedValue(START)
const { value: slow, tweenTo: tweenSlow, jumpTo: jumpSlow } = useTweenedValue(START)

let fastTimer: ReturnType<typeof setInterval> | undefined
let slowTimer: ReturnType<typeof setInterval> | undefined

/**
 * Шаг занимает весь интервал до следующего — тогда движение читается как
 * непрерывное. На конце шкалы плавный переход был бы перемоткой назад через
 * всё кольцо, поэтому там значение возвращается мгновенно.
 */
function advance(
  current: number,
  tweenTo: (value: number, duration: number) => void,
  jumpTo: (value: number) => void,
  duration: number,
): void {
  const next = current + STEP

  if (next > 100)
    jumpTo(0)
  else tweenTo(next, duration)
}

/**
 * Само движение здесь и есть предмет демо, поэтому под «уменьшить движение»
 * таймеры не запускаются вовсе: кольца остаются на стартовых значениях.
 * `matchMedia` читается в `onMounted`, а не в теле `setup`.
 */
onMounted(() => {
  if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches)
    return

  fastTimer = setInterval(advance, 1000, fast.value, tweenFast, jumpFast, 1000)
  slowTimer = setInterval(advance, 5000, slow.value, tweenSlow, jumpSlow, 5000)
})

onBeforeUnmount(() => {
  clearInterval(fastTimer)
  clearInterval(slowTimer)
})
</script>

<template>
  <div class="flex flex-wrap items-start gap-10">
    <div class="grid justify-items-center gap-2">
      <GrProgressCircle :value="fast" size="lg" show-value aria-label="Обновление раз в секунду" />
      <span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
        +{{ STEP }} % раз в секунду
      </span>
    </div>

    <div class="grid justify-items-center gap-2">
      <GrProgressCircle :value="slow" size="lg" tone="info" show-value aria-label="Обновление раз в пять секунд" />
      <span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
        +{{ STEP }} % раз в пять секунд
      </span>
    </div>

    <p class="max-w-xs text-[length:var(--gr-text-sm)] text-[var(--gr-muted-fg)]">
      Оба кольца прибавляют по {{ STEP }} % на шаг, но левое делает это раз в секунду, а правое — раз в пять,
      и каждый шаг растянут на весь интервал до следующего: движение идёт от прежней точки к новой, а не рывком.
    </p>
  </div>
</template>

Upload

Uploaddepends on the showcase environment
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import IconX from '~icons/lucide/x'
import { GrButton, GrProgressCircle } from '@feugene/granularity'

type Stage = 'idle' | 'connecting' | 'uploading' | 'done'

const stage = ref<Stage>('idle')
const value = ref(0)

let timer: ReturnType<typeof setInterval> | undefined

function stop() {
  if (timer)
    clearInterval(timer)
  timer = undefined
}

function start() {
  stop()
  stage.value = 'connecting'
  value.value = 0

  // Пока сервер не ответил, доли прогресса нет — это и есть `indeterminate`.
  setTimeout(() => {
    stage.value = 'uploading'
    timer = setInterval(() => {
      value.value = Math.min(100, value.value + 7)
      if (value.value >= 100) {
        stop()
        stage.value = 'done'
      }
    }, 220)
  }, 900)
}

function cancel() {
  stop()
  stage.value = 'idle'
  value.value = 0
}

onBeforeUnmount(stop)
</script>

<template>
  <div class="flex flex-wrap items-center gap-6">
    <GrProgressCircle
      :value="value"
      :indeterminate="stage === 'connecting'"
      :tone="stage === 'done' ? 'success' : 'primary'"
      size="lg"
      status-icon
      show-value
      aria-label="Загрузка файла"
    >
      <GrButton
        v-if="stage === 'uploading'"
        variant="ghost"
        size="xs"
        aria-label="Отменить загрузку"
        @click="cancel"
      >
        <IconX class="h-3 w-3" />
      </GrButton>
    </GrProgressCircle>

    <div class="grid gap-2">
      <span class="text-[length:var(--gr-text-sm)] text-[var(--gr-muted-fg)]">
        {{ stage === 'idle' ? 'Готов к загрузке' : stage === 'connecting' ? 'Соединение…' : stage === 'uploading' ? 'Загружаем…' : 'Файл загружен' }}
      </span>
      <GrButton size="sm" :disabled="stage === 'connecting' || stage === 'uploading'" @click="start">
        Загрузить
      </GrButton>
    </div>
  </div>
</template>

Component documentationAll components