Motiva
Мои коллекции
← каталог

Progress Ring

Кольцо прогресса: дуга через stroke-dashoffset, число доезжает до значения rAF-анимацией, которая засыпает по достижении цели. Цифры с tabular-nums — не прыгают.

cssloadrevealbasic~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
value?number72Значение 0–100 (формат «72»).
size?number120Диаметр, px (формат «120»).
stroke?number10Толщина кольца, px (формат «10»).
label?string"готово"Подпись под числом.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/progress-ring.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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