Overview

A determinate circular meter — not a spinner. The arc springs to the target with bounce: 0. Reduced motion snaps the arc instantly. Center label uses tabular-nums so digits don’t shift.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/progress-ring.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/progress-ring.tsx
tsx
"use client";
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";import {  motion,  useMotionValue,  useReducedMotion,  useTransform,  animate,} from "motion/react";import { cn } from "@/lib/utils";
export type ProgressRingProps = {  value: number;  size?: number;  strokeWidth?: number;  label?: string;  className?: string;};
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.3 };
function DigitReadout({  value,  className,}: {  value: string;  className?: string;}) {  const groupRef = useRef<HTMLSpanElement>(null);  const [animating, setAnimating] = useState(true);  const chars = value.split("");
  useLayoutEffect(() => {    const el = groupRef.current;    if (!el) return;    setAnimating(false);    void el.offsetHeight;    setAnimating(true);  }, [value]);
  return (    <span      ref={groupRef}      className={cn("t-digit-group", animating && "is-animating", className)}    >      {chars.map((ch, i) => {        const fromEnd = chars.length - 1 - i;        const stagger =          fromEnd === 1 ? "1" : fromEnd === 0 ? "2" : undefined;        return (          <span            key={`${i}-${ch}`}            className="t-digit"            {...(stagger ? { "data-stagger": stagger } : {})}          >            {ch}          </span>        );      })}    </span>  );}
export function ProgressRing({  value,  size = 96,  strokeWidth = 8,  label,  className,}: ProgressRingProps) {  const reduce = useReducedMotion();  const uid = useId();  const clamped = Math.min(100, Math.max(0, value));  const r = (size - strokeWidth) / 2;  const c = 2 * Math.PI * r;  const progress = useMotionValue(reduce ? clamped : 0);  const offset = useTransform(progress, (v) => c - (v / 100) * c);  const display = useTransform(progress, (v) => Math.round(v));
  useEffect(() => {    if (reduce) {      progress.set(clamped);      return;    }    const controls = animate(progress, clamped, SPRING);    return () => controls.stop();  }, [clamped, progress, reduce]);
  const [pct, setPct] = React.useState(clamped);  useEffect(() => display.on("change", (v) => setPct(v)), [display]);
  // Pop the center digits when the target value changes (not every spring tick)  const displayValue = label ?? `${Math.round(clamped)}%`;
  return (    <div      className={cn(        "relative inline-flex items-center justify-center",        className,      )}      style={{ width: size, height: size }}      role="progressbar"      aria-valuenow={pct}      aria-valuemin={0}      aria-valuemax={100}      aria-label={label ?? "Progress"}    >      <svg width={size} height={size} className="-rotate-90">        <circle          cx={size / 2}          cy={size / 2}          r={r}          fill="none"          stroke="var(--color-rule)"          strokeWidth={strokeWidth}        />        <motion.circle          cx={size / 2}          cy={size / 2}          r={r}          fill="none"          stroke="var(--color-ink)"          strokeWidth={strokeWidth}          strokeLinecap="round"          strokeDasharray={c}          style={{ strokeDashoffset: offset }}        />      </svg>      {label ? (        <span          id={uid}          className="absolute text-sm font-semibold tabular-nums text-[var(--color-ink)]"        >          {label}        </span>      ) : (        <DigitReadout          value={displayValue}          className="absolute text-sm font-semibold tabular-nums text-[var(--color-ink)]"        />      )}    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { ProgressRing } from "@/components/ui/progress-ring";
<ProgressRing value={72} size={96} strokeWidth={8} />

Props

PropTypeRequiredDefaultDescription
valuenumberYesProgress 0–100.
sizenumberNo96Outer diameter in pixels.
strokeWidthnumberNo8Ring stroke width.
labelstringNoOverride center label (defaults to percent).

Best Practices

  1. Prefer this for determinate progress; use Loader for indeterminate waits.
  2. Keep strokeWidth proportional to size so the ring doesn’t look heavy.
  3. Pass a custom label when the center should show units, not percent.