Progress Ring
Кольцо прогресса: дуга через stroke-dashoffset, число доезжает до значения rAF-анимацией, которая засыпает по достижении цели. Цифры с tabular-nums — не прыгают.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| value? | number | 72 | Значение 0–100. |
| size? | number | 120 | Диаметр, px. |
| stroke? | number | 10 | Толщина кольца, px. |
| label? | string | "готово" | Подпись под числом. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/progress-ring.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef, useState } from "react";
export interface ProgressRingProps {
/** Значение 0–100. */
value?: number;
/** Диаметр, px. */
size?: number;
/** Толщина кольца, px. */
stroke?: number;
/** Подпись под числом. */
label?: string;
}
/**
* Кольцо прогресса: дуга рисуется через `stroke-dashoffset`, число доезжает до значения
* анимацией по rAF, которая ЗАСЫПАЕТ по достижении цели. Считается один раз при смене
* значения — фонового лупа нет. Число подаётся с `tabular-nums`, поэтому не «прыгает».
*/
export function ProgressRing({ value = 72, size = 120, stroke = 10, label = "готово" }: ProgressRingProps) {
const r = (size - stroke) / 2;
const circumference = 2 * Math.PI * r;
const [shown, setShown] = useState(0);
const raf = useRef(0);
useEffect(() => {
const target = Math.min(100, Math.max(0, value));
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) {
setShown(target);
return;
}
const start = performance.now();
const from = 0;
const tick = (t: number) => {
const k = Math.min(1, (t - start) / 900);
const eased = 1 - Math.pow(1 - k, 3); // easeOutCubic
setShown(from + (target - from) * eased);
if (k < 1) raf.current = requestAnimationFrame(tick); // луп живёт ровно до цели
};
raf.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf.current);
}, [value]);
return (
<div style={{ position: "relative", width: size, height: size, display: "grid", placeItems: "center" }}>
<svg width={size} height={size} style={{ transform: "rotate(-90deg)" }} aria-hidden>
<defs>
<linearGradient id="prg-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="var(--ds-accent, oklch(0.78 0.16 300))" />
<stop offset="1" stopColor="oklch(0.75 0.15 210)" />
</linearGradient>
</defs>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--ds-border, oklch(0.28 0.02 270))" strokeWidth={stroke} />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="url(#prg-grad)"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - shown / 100)}
/>
</svg>
<div
style={{
position: "absolute",
textAlign: "center",
color: "var(--ds-fg, oklch(0.95 0.01 265))",
fontFamily: "var(--ds-font, system-ui, sans-serif)",
}}
role="progressbar"
aria-valuenow={Math.round(shown)}
aria-valuemin={0}
aria-valuemax={100}
>
<div style={{ fontSize: size * 0.22, fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>
{Math.round(shown)}%
</div>
<div style={{ fontSize: size * 0.085, opacity: 0.55, fontFamily: "var(--ds-font-mono, ui-monospace, monospace)" }}>
{label}
</div>
</div>
</div>
);
}