All patterns

Determinate Progress Ring

A ring fills from empty to full while the percentage counts up inside it.

loadingminimalpremiumautomatic · finite · intermediate · ~1.4s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

214 lines · react + motion only
import { useEffect, useRef } from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Determinate Progress Ring
 *
 * A ring that fills from empty to its target while the percentage
 * counts up in the middle. Arc and number are driven by one motion
 * value, so they can never disagree.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `value`, `size`, `color`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ProgressRingProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Target percentage, 0–100. */
  value?: number;
  /** Ring diameter in px. */
  size?: number;
  /** Ring thickness in px. */
  strokeWidth?: number;
  /** Filled arc color. */
  color?: string;
  /** Unfilled track color. A translucent neutral, so it reads on light and dark. */
  trackColor?: string;
  /** Accessible label announced to screen readers. */
  label?: string;
  /** Fires once the ring reaches its target. */
  onComplete?: () => void;
};

type VariantConfig = {
  scaleFrom: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  fillSeconds: number;
  fillEase: "easeOut" | "easeInOut";
  fillDelay: number;
};

// Quality rule: the entrance spring sits at or above critical damping —
// a progress ring that wobbles reads as a toy. Variants differ in how
// much the ring announces itself and how long the count takes to read.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // No entrance, quick front-loaded fill. For inline and in-row progress
  // where the ring is one of several things on screen.
  subtle: {
    scaleFrom: 1,
    spring: { type: "spring", stiffness: 460, damping: 46 },
    fillSeconds: 1.0,
    fillEase: "easeOut",
    fillDelay: 0.04,
  },
  // A small settle in, then an eased fill. The all-purpose setting.
  default: {
    scaleFrom: 0.94,
    spring: { type: "spring", stiffness: 420, damping: 38 },
    fillSeconds: 1.25,
    fillEase: "easeInOut",
    fillDelay: 0.1,
  },
  // Larger entrance and a longer count — for a full-screen export or
  // install moment where watching the number climb is the point.
  playful: {
    scaleFrom: 0.86,
    spring: { type: "spring", stiffness: 380, damping: 32 },
    fillSeconds: 1.5,
    fillEase: "easeInOut",
    fillDelay: 0.14,
  },
};

export default function ProgressRing({
  variant = "default",
  value = 100,
  size = 104,
  strokeWidth = 8,
  color = "#7C7CF0",
  trackColor = "rgba(127, 127, 140, 0.22)",
  label = "Progress",
  onComplete,
}: ProgressRingProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const target = Math.min(100, Math.max(0, value));

  // One source of truth for both the arc and the digits.
  const progress = useMotionValue(0);
  const pathLength = useTransform(progress, [0, 100], [0, 1]);
  const readout = useTransform(progress, (current) => Math.round(current));

  // The callback lives in a ref so an inline arrow from the parent can't
  // re-trigger the effect and restart the fill halfway through.
  const onCompleteRef = useRef(onComplete);
  useEffect(() => {
    onCompleteRef.current = onComplete;
  }, [onComplete]);

  useEffect(() => {
    // Reduced motion: land on the answer. The arc and the number are the
    // information — the climb was only ever the delivery.
    if (reduceMotion) {
      progress.set(target);
      onCompleteRef.current?.();
      return;
    }
    const controls = animate(progress, target, {
      duration: cfg.fillSeconds,
      ease: cfg.fillEase,
      delay: cfg.fillDelay,
      onComplete: () => onCompleteRef.current?.(),
    });
    return () => controls.stop();
  }, [target, reduceMotion, cfg, progress]);

  const center = size / 2;
  const radius = (size - strokeWidth) / 2;

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      // The target, not the animated frame: assistive tech should hear
      // where the work actually stands, not follow the count.
      aria-valuenow={target}
      style={{ position: "relative", width: size, height: size }}
    >
      <motion.svg
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        fill="none"
        initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: cfg.scaleFrom }}
        animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
        transition={
          reduceMotion
            ? { duration: 0.2, ease: "easeOut" }
            : { ...cfg.spring, opacity: { duration: 0.2, ease: "easeOut" } }
        }
        style={{ display: "block" }}
      >
        <circle
          cx={center}
          cy={center}
          r={radius}
          stroke={trackColor}
          strokeWidth={strokeWidth}
        />
        {/* The dash offset starts at 3 o'clock, so the group is rotated
            to put zero at the top where people read it from. */}
        <g transform={`rotate(-90 ${center} ${center})`}>
          <motion.circle
            cx={center}
            cy={center}
            r={radius}
            stroke={color}
            strokeWidth={strokeWidth}
            strokeLinecap="round"
            style={{ pathLength }}
          />
        </g>
      </motion.svg>

      {/* The readout fades but never scales, and the entrance spring is
          on the ring only — scaling digits is the fastest way to make a
          progress indicator look cheap. Tabular figures keep the number
          from twitching sideways as it climbs. */}
      <motion.div
        aria-hidden
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={{
          duration: 0.25,
          ease: "easeOut",
          delay: reduceMotion ? 0 : cfg.fillDelay,
        }}
        style={{
          position: "absolute",
          inset: 0,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontVariantNumeric: "tabular-nums",
          fontFeatureSettings: '"tnum"',
          lineHeight: 1,
        }}
      >
        <motion.span style={{ fontSize: size * 0.26, fontWeight: 600 }}>
          {readout}
        </motion.span>
        <span
          style={{
            fontSize: size * 0.15,
            fontWeight: 600,
            opacity: 0.5,
            marginLeft: 1,
          }}
        >
          %
        </span>
      </motion.div>
    </div>
  );
}

About this pattern

For work whose end is genuinely known: uploads, exports, batch imports, multi-step installs. The arc and the number are driven by a single value, so they can never disagree, and the readout uses tabular figures so the digits climb without twitching sideways. The number never scales or bounces — a figure that deforms while it counts stops reading as data and starts reading as decoration. Use a breathing indicator instead whenever the finish line is a guess.

File uploadExport progressBatch importInstall step

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Today
    412 kcalMove8,240Steps9 hrsStand
    HomeSearchActivityProfile
    Activity summary

    Determinate rings whose fill maps directly onto a known value.

Related patterns