Display

ProgressRing

Circular progress indicator with an animated counter

Preview

90%

size
value
max
suffix

Code

import css from "./ProgressRing.module.css";
import { useEffect, useRef, useState } from "react";

type Size = "sm" | "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface ProgressRingProps {
  size?: Size | ResponsiveSize;
  customSize?: string;
  value: number;
  max: number;
  // Named after the attribute it becomes: the ring draws only the number.
  "aria-label": string;
  prefix?: string;
  suffix?: string;
}

const DURATION = 1200;
const easeOutCubic = (progress: number) => 1 - Math.pow(1 - progress, 3);

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function ProgressRing({
  size,
  customSize,
  value,
  max,
  "aria-label": ariaLabel,
  prefix,
  suffix,
}: ProgressRingProps) {
  const ring = useRef<HTMLDivElement>(null);
  const track = useRef<HTMLDivElement>(null);
  const shown = useRef({ value: 0, fill: 0 });

  const [inView, setInView] = useState(false);
  const [display, setDisplay] = useState(0);

  useEffect(() => {
    if (!ring.current) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setInView(true);
          observer.disconnect();
        }
      },
      { threshold: 0.4 },
    );

    observer.observe(ring.current);
    return () => observer.disconnect();
  }, []);

  // Firefox does not repaint a conic-gradient while an animated @property changes, so JS drives
  // --ring-fill per frame.
  // TODO(firefox): revert to pure CSS once that lands — see git history.
  useEffect(() => {
    if (!inView) return;

    const fill = max > 0 ? (value / max) * 100 : 0;

    const reduce = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;
    if (reduce) {
      shown.current = { value, fill };
      setDisplay(value);
      track.current?.style.setProperty("--ring-fill", `${fill}%`);
      return;
    }

    const from = shown.current;
    let raf = 0;
    let start: number | null = null;

    const tick = (now: number) => {
      if (start === null) start = now;
      const progress = Math.min((now - start) / DURATION, 1);
      const eased = easeOutCubic(progress);

      shown.current =
        progress < 1
          ? {
              value: from.value + (value - from.value) * eased,
              fill: from.fill + (fill - from.fill) * eased,
            }
          : { value, fill };
      setDisplay(progress < 1 ? Math.round(shown.current.value) : value);
      track.current?.style.setProperty("--ring-fill", `${shown.current.fill}%`);

      if (progress < 1) raf = requestAnimationFrame(tick);
    };

    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [inView, value, max]);

  return (
    <div
      ref={ring}
      className={`
        ${css.ring}
        ${size ? getSizeClasses(size) : ""}
      `}
      style={
        customSize
          ? ({ "--ring-diameter": customSize } as React.CSSProperties)
          : undefined
      }
    >
      <div
        ref={track}
        className={css.ring_track}
        role="progressbar"
        aria-valuenow={value}
        aria-valuemin={0}
        aria-valuemax={max}
        aria-valuetext={`${prefix ?? ""}${value}${suffix ?? ""}`}
        aria-label={ariaLabel}
      ></div>
      <p className={`${css.ring_value} ${inView ? "" : css.pending}`}>
        {prefix && <span className={css.value_prefix}>{prefix}</span>}
        {inView ? display : value}
        {suffix && <span className={css.value_suffix}>{suffix}</span>}
      </p>
    </div>
  );
}

Props

PropTypeRequiredDescription
size"sm" | "md" | "lg" | ResponsiveSize—Ring diameter. Omit it only if you pass customSize. Fixed or responsive.
customSizestring—Custom diameter (any CSS unit); replaces size.
valuenumberrequiredCenter number and progress; counts from 0 on entering the viewport (honors reduced-motion).
maxnumberrequiredValue that fills the whole ring; the arc is value / max.
aria-labelstringrequiredName of the ring for screen readers; the ring only draws the number.
prefixstring—Text before the number (e.g. "+").
suffixstring—Text after the number (e.g. "%").