← каталог
Progress Ring
Кольцо прогресса: дуга через stroke-dashoffset, число доезжает до значения rAF-анимацией, которая засыпает по достижении цели. Цифры с tabular-nums — не прыгают.
cssloadrevealbasic~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| value? | number | 72 | Значение 0–100 (формат «72»). |
| size? | number | 120 | Диаметр, px (формат «120»). |
| stroke? | number | 10 | Толщина кольца, px (формат «10»). |
| label? | string | "готово" | Подпись под числом. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/progress-ring.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef, useState } from "react";
export interface ProgressRingProps {
/** Значение 0–100 (формат «72»). */
value?: number;
/** Диаметр, px (формат «120»). */
size?: number;
/** Толщина кольца, px (формат «10»). */
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>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0