GrProgressCircle
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 needed —
shapebreaks the ring open when that is clearer; - the state has to be shown with a sign —
statusIconreplaces the number with a success or a failure on completion.
When to take something else
| Need | Take |
|---|---|
| There is enough room across the width | GrProgressBar |
| The share is unknown, the content is already there | GrLoading |
| A number without a gauge | GrStatistic |
| The shares of several parts of a whole | GrChartPie |
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.
showValueprints percentages, andformatValuesets a text of your own along witharia-valuetext;statusIconreplaces the number with a tick atvalue >= 100and with a cross attone="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.
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
bufferis 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
GrAvatarandGrIconexactly; pointed adjustment goes through--gr-progress-circle-size, and the geometry of the arc is computed inviewBoxunits and does not depend on the diameter; - there is no gradient along the arc — it requires a
<linearGradient>with anidof 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…
<GrProgressCircle />Install
npm i @feugene/granularityImport
import { GrProgressCircle } from '@feugene/granularity/components/GrProgressCircle'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | undefined | The colour tone of the arc. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The diameter of the ring by the scale of the package. |
ariaLabel | string | undefined | undefined | The label for a screen reader (mandatory if there is no visible heading beside it). |
shape | "circle" | "dashboard" | undefined | undefined | A closed ring or an arc with a cut-out at the bottom. |
value | number | undefined | 0 | The current value `0..100`; values out of range are clamped, and `NaN` becomes `0`. |
indeterminate | boolean | undefined | false | The progress is unknown: the arc runs around the circle, and no value is announced outside. |
thickness | number | undefined | undefined | The thickness of the stroke as a percentage of the diameter. Unset — by the `size` step. |
showValue | boolean | undefined | false | Show the value in the centre of the ring. The default slot is stronger. |
formatValue | ((value: number) => string) | undefined | undefined | A format of your own for the value. It governs both the label and `aria-valuetext`. |
statusIcon | boolean | undefined | false | On completion — a tick, and with `tone="danger"` — a cross instead of the value. |
trackless | boolean | undefined | undefined | Remove the track: on top of a picture the empty part of the ring is only noise. |
Slots
| Slot | Type | Description |
|---|---|---|
default | { value: number; } | The content of the centre instead of the value: an icon, two lines, a cancel button. |
Examples 4
Basic
xssmmdlg<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
<!-- 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
Оба кольца прибавляют по 5 % на шаг, но левое делает это раз в секунду, а правое — раз в пять, и каждый шаг растянут на весь интервал до следующего: движение идёт от прежней точки к новой, а не рывком.
<!-- 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
<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>