GrProgressCircle
Берут, когда места по ширине нет.
Когда брать
- места по ширине нет — плитка дашборда, ячейка, карточка показателя;
- значение важнее хода — число в центре читается как показатель, а не как ожидание;
- нужна шкала-«спидометр» —
shapeразрывает кольцо, когда так понятнее; - состояние нужно значком —
statusIconзаменяет число на успех или отказ по завершении.
Когда взять другое
| Нужно | Берите |
|---|---|
| Места по ширине хватает | GrProgressBar |
| Доля неизвестна, контент уже есть | GrLoading |
| Число без шкалы | GrStatistic |
| Доли нескольких частей целого | GrChartPie |
Центр лежит рядом с ролью, а не внутри неё
Разметка нарочно двухслойная: role="progressbar" несёт только SVG, а
содержимое центра — его сосед с абсолютным позиционированием.
Причина не косметическая. Роль-виджет объявляет своих потомков
презентационными, и кнопка «Отменить» внутри кольца — первый же сценарий
аплоада — оказалась бы nested-interactive: скринридер потерял бы и кнопку, и
индикатор. Слот центра обязан оставаться местом, куда можно положить что
угодно, включая интерактив.
Сам слой центра не ловит указатель (pointer-events: none) — иначе он накрыл
бы кольцо; интерактив внутри включает события себе обратно.
Значение, иконка, слот
Приоритет содержимого центра: слот → иконка итога → значение.
showValueпечатает проценты,formatValueзадаёт свой текст и заодноaria-valuetext;statusIconменяет число на галочку приvalue >= 100и на крест приtone="danger"— то есть только в терминальных состояниях;- слот по умолчанию получает уже клампнутое значение и перебивает оба варианта.
Пустой слот (например, кнопка под
v-if) центр не занимает — считается содержимое, а не факт передачи слота.
Значение в центре помещается начиная с sm; на xs его либо не показывают,
либо уменьшают кегль токеном --gr-progress-circle-value-size.
Формы
circle — замкнутое кольцо, отсчёт с двенадцати часов по часовой.
dashboard — три четверти окружности с вырезом строго снизу: под значением
освобождается место для подписи, а шкала читается как спидометр. Значение в
обеих формах означает одно и то же — доля пройденного, — меняется только длина
дорожки.
Тона общие с полосой
Дуга красится теми же переменными, что и GrProgressBar
(--gr-progress-bg, --gr-progress-success-bg и соседи): слой темизации у
прогресса один, и потребитель, перекрасивший его, ждёт, что круг и полоса
сойдутся. Каждая переменная берётся с фолбэком на роль темы — при гранулярном
импорте одного круга покомпонентная тема полосы не подключается, и без фолбэка
дуга осталась бы без цвета.
Своя у круга только геометрия: --gr-progress-circle-size,
--gr-progress-circle-track, --gr-progress-circle-cap,
--gr-progress-circle-value-size.
Неизвестный прогресс
indeterminate пускает дугу по кругу и не объявляет значения:
aria-valuenow не выставляется, подпись не печатается.
Под prefers-reduced-motion компонент показывает замкнутое нейтральное кольцо,
а не останавливает вращение. Остановленная четверть дуги читалась бы как
«прогресс 25 %» — тот же довод, по которому у GrProgressBar своя ветка
reduce, а не общий кламп анимаций.
Границы
bufferне переносится с полосы: слой «загружено с запасом» читается на шкале, которую видно слева направо целиком; на кольце две вложенные дуги выглядят одной толстой;- размер только по шкале пакета — пиксельный escape-hatch есть ровно у
GrAvatarиGrIcon; точечная подгонка идёт через--gr-progress-circle-size, а геометрия дуги считается в единицахviewBoxи от диаметра не зависит; - градиента по дуге нет — он требует
<linearGradient>со своимidв каждом экземпляре, то есть ещё одного источника расхождений гидрации; - сегментов и рисок нет — кольцо с делениями это индикатор шагов, а не прогресса.
Playground 10
Загружается…
<GrProgressCircle />Установка
npm i @feugene/granularityИмпорт
import { GrProgressCircle } from '@feugene/granularity/components/GrProgressCircle'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | undefined | Цветовая тональность дуги. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Диаметр кольца по шкале пакета. |
ariaLabel | string | undefined | undefined | Метка для скринридера (обязательна, если рядом нет видимого заголовка). |
shape | "circle" | "dashboard" | undefined | undefined | Замкнутое кольцо или дуга с вырезом снизу. |
value | number | undefined | 0 | Текущее значение `0..100`; выходящие за границы клампятся, `NaN` → `0`. |
indeterminate | boolean | undefined | false | Прогресс неизвестен: дуга бежит по кругу, значение наружу не объявляется. |
thickness | number | undefined | undefined | Толщина обводки в процентах диаметра. Не задана — по ступени `size`. |
showValue | boolean | undefined | false | Показать значение в центре кольца. Слот по умолчанию сильнее. |
formatValue | ((value: number) => string) | undefined | undefined | Свой формат значения. Управляет и подписью, и `aria-valuetext`. |
statusIcon | boolean | undefined | false | На завершении — галочка, при `tone="danger"` — крест вместо значения. |
trackless | boolean | undefined | undefined | Убрать дорожку: поверх картинки пустая часть кольца только шумит. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | { value: number; } | Содержимое центра вместо значения: иконка, две строки, кнопка отмены. |
Примеры 4
Размеры и тона
Диаметр — по шкале пакета, толщина обводки идёт следом. Тона берутся из той же темы, что и у линейного индикатора: перекрасив --gr-progress-bg, вы перекрасите оба.
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>Плитка метрики
shape="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>Живое значение
Два кольца на одной шкале 0…100: левое прибавляет пять процентов раз в секунду, правое — раз в пять секунд. Дуга догоняет новое значение переходом, поэтому частый шаг читается как непрерывное движение, а редкий — как отдельные приращения.
Оба кольца прибавляют по 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>Аплоад: от «соединяемся» до галочки
Пока доли прогресса нет — indeterminate; дальше значение, а на завершении statusIcon меняет число на галочку. Кнопка отмены живёт в центре кольца и остаётся кликабельной: центр лежит рядом с role="progressbar", а не внутри него.
<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>