Skip to content

Circular Progress Ring

A progress ring with a gradient stroke and a glowing head, tick marks that light up behind the arc, a centre value whose digits roll, and an indeterminate mode with a rotating arc. Springs to any value. No dependencies.

DOM + CSS0 deps1 file · 16 KB TSXreduced-motion aware

Freeminimalneon
Preview

Props

72
120
10
1.2
Indeterminate
Tick marks
No runtime dependencies
"use client";

import { useEffect, useId, useRef } from "react";

export type ProgressRingProps = {
  /** Value to fill to, 0-100. Changing it springs the ring to the new value. */
  target?: number;
  /** Outer size in px (the ring plus its tick marks). */
  size?: number;
  /** Stroke width of the ring in px. */
  strokeWidth?: number;
  /** Roughly how long the spring takes to settle, in seconds. */
  duration?: number;
  /** Shows a rotating arc instead of a value (loading with unknown length). */
  indeterminate?: boolean;
  /** Draws the tick marks around the ring (hidden automatically under 72px). */
  ticks?: boolean;
  /** Small caption under the value; also the accessible name. */
  label?: string;
  /** Gradient start colour (hex). Default is the classic red. */
  from?: string;
  /** Gradient end colour (hex). */
  to?: string;
  /** Goes on the outer element. Text colour (currentColor) paints the track, ticks and numerals. */
  className?: string;
};

const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const SEGMENTS = 90;
const TAU = Math.PI * 2;
const nowMs = () => performance.now();

function rgb(hex: string, fallback: [number, number, number]): [number, number, number] {
  if (!HEX.test(hex)) return fallback;
  const h = hex.slice(1);
  const f = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
  return [parseInt(f.slice(0, 2), 16), parseInt(f.slice(2, 4), 16), parseInt(f.slice(4, 6), 16)];
}

function mix(a: [number, number, number], b: [number, number, number], t: number) {
  const k = Math.min(1, Math.max(0, t));
  return (
    "rgb(" +
    Math.round(a[0] + (b[0] - a[0]) * k) +
    "," +
    Math.round(a[1] + (b[1] - a[1]) * k) +
    "," +
    Math.round(a[2] + (b[2] - a[2]) * k) +
    ")"
  );
}

const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
const r3 = (n: number) => Math.round(n * 1000) / 1000;

const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
const FADE = "linear-gradient(to bottom, transparent, #000 24%, #000 76%, transparent)";

function Column({ colRef, stripRef }: { colRef: (el: HTMLSpanElement | null) => void; stripRef: (el: HTMLSpanElement | null) => void }) {
  return (
    <span
      ref={colRef}
      className="inline-block overflow-hidden align-baseline"
      style={{ height: "1.3em", marginTop: "-0.15em", marginBottom: "-0.15em", width: "0.62em", WebkitMaskImage: FADE, maskImage: FADE }}
    >
      <span ref={stripRef} className="block will-change-transform" style={{ transform: "translateY(0%)" }}>
        {DIGITS.map((d, i) => (
          <span key={i} className="block text-center" style={{ height: "1.3em", lineHeight: "1.3em" }}>
            {d}
          </span>
        ))}
      </span>
    </span>
  );
}

export function ProgressRing({
  target = 72,
  size = 120,
  strokeWidth = 10,
  duration = 1.2,
  indeterminate = false,
  ticks = true,
  label,
  from = "#ff2d2d",
  to = "#ff8a3d",
  className,
}: ProgressRingProps) {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const rootRef = useRef<HTMLDivElement>(null);
  const maskCircle = useRef<SVGCircleElement>(null);
  const headCore = useRef<SVGCircleElement>(null);
  const leadRef = useRef<SVGPathElement>(null);
  const headHalo = useRef<SVGCircleElement>(null);
  const tickG = useRef<SVGGElement>(null);
  const arcG = useRef<SVGGElement>(null);
  const valueEl = useRef<HTMLSpanElement>(null);
  const cols = useRef<(HTMLSpanElement | null)[]>([null, null, null]);
  const strips = useRef<(HTMLSpanElement | null)[]>([null, null, null]);
  const api = useRef<{ wake: () => void; point: (f: number | null) => void } | null>(null);
  const live = useRef({ target, indeterminate, inView: false, reduced: false });

  const showTicks = ticks && size >= 72;
  const sw = clamp(strokeWidth, 2, size * 0.3);
  const c = size / 2;
  const tickLen = clamp(size * 0.05, 3, 8);
  const bump = tickLen * 1.3;
  const rb = showTicks ? c - 1.5 - bump - tickLen * 0.2 : c;
  const R = showTicks ? rb - 4 - sw / 2 : c - sw / 2;
  const C = TAU * R;
  const fromRgb = rgb(from, [255, 45, 45]);
  const toRgb = rgb(to, [255, 138, 61]);
  const cfg = useRef({ c, R, C, rb, tickLen, bump, sw, fromRgb, toRgb, showTicks, duration });
  const maskId = "pr-m-" + uid;
  const blurId = "pr-b-" + uid;

  useEffect(() => {
    cfg.current = { c, R, C, rb, tickLen, bump, sw, fromRgb, toRgb, showTicks, duration };
    live.current.target = clamp(target, 0, 100);
    live.current.indeterminate = indeterminate;
    api.current?.wake();
  });

  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    live.current.reduced = mq.matches;
    const onMq = () => {
      live.current.reduced = mq.matches;
      api.current?.wake();
    };
    mq.addEventListener("change", onMq);
    let io: IntersectionObserver | null = null;
    if (typeof IntersectionObserver === "undefined") {
      live.current.inView = true;
    } else {
      io = new IntersectionObserver(
        (entries) => {
          if (entries.some((e) => e.isIntersecting)) {
            live.current.inView = true;
            io?.disconnect();
            api.current?.wake();
          }
        },
        { threshold: 0.3 },
      );
      io.observe(el);
    }

    const s = { v: 0, vel: 0, ind: 0, h: 0, hT: 0, hF: 0, t0: nowMs(), last: nowMs(), raf: 0, tickState: [] as number[] };
    const tickEls = () => (tickG.current ? Array.from(tickG.current.children) : []);

    const draw = (ts: number) => {
      const g = cfg.current;
      const L = live.current;
      const tt = (ts - s.t0) / 1000;
      const vC = clamp(s.v, 0, 100);
      let lenF = vC / 100;
      let rot = 0;
      if (s.ind > 0.001) {
        const spin = L.reduced ? 0 : ((tt * 240) % 360);
        const Lf = L.reduced ? 0.28 : 0.1 + 0.5 * (0.5 - 0.5 * Math.cos((TAU * tt) / 2.4));
        lenF = lenF + (Lf - lenF) * s.ind;
        rot = spin * s.ind;
      }
      const mc = maskCircle.current;
      if (mc) {
        mc.setAttribute("stroke-dasharray", (lenF * g.C).toFixed(2) + " " + g.C.toFixed(2));
        mc.setAttribute("transform", "rotate(" + (rot - 90).toFixed(2) + " " + g.c + " " + g.c + ")");
        mc.setAttribute("opacity", lenF * g.C > 0.05 ? "1" : "0");
      }
      const theta = rot + lenF * 360;
      const rad = ((theta - 90) * Math.PI) / 180;
      const hx = g.c + g.R * Math.cos(rad);
      const hy = g.c + g.R * Math.sin(rad);
      const frac = (((theta / 360) % 1) + 1) % 1;
      const lead = leadRef.current;
      if (lead) lead.setAttribute("opacity", (1 - clamp((lenF - 0.93) / 0.06, 0, 1)).toFixed(3));
      const done = clamp((vC - 98.5) / 1.5, 0, 1) * (1 - s.ind);
      const headOp = lenF * g.C > 0.05 ? 1 - done : 0;
      const core = headCore.current;
      const halo = headHalo.current;
      if (core && halo) {
        core.setAttribute("cx", hx.toFixed(2));
        core.setAttribute("cy", hy.toFixed(2));
        core.setAttribute("opacity", headOp.toFixed(3));
        core.setAttribute("r", (g.sw * (0.2 + 0.06 * s.h)).toFixed(2));
        halo.setAttribute("cx", hx.toFixed(2));
        halo.setAttribute("cy", hy.toFixed(2));
        halo.setAttribute("r", (g.sw * (0.85 + 0.45 * s.h)).toFixed(2));
        halo.setAttribute("fill", mix(g.fromRgb, g.toRgb, frac));
        halo.setAttribute("opacity", (headOp * (0.75 + 0.25 * s.h)).toFixed(3));
      }
      if (g.showTicks) {
        const els = tickEls();
        const n = els.length;
        for (let i = 0; i < n; i++) {
          const f = i / n;
          const rel = (((f - rot / 360) % 1) + 1) % 1;
          const lit = lenF > 0.0005 && rel <= lenF + 1e-6;
          const trail = lenF > 0 ? rel / lenF : 0;
          let op = lit ? 0.3 + 0.7 * Math.pow(trail, 2.2) : 0.16;
          const dd = Math.abs(f - s.hF);
          const d = Math.min(dd, 1 - dd);
          const bp = s.h * Math.exp(-Math.pow(d / 0.045, 2));
          op = Math.min(1, op + 0.55 * bp);
          const major = i % (n / 4) === 0;
          const len = g.tickLen * (major ? 1.45 : 1) + g.bump * bp;
          const t = els[i] as SVGLineElement;
          t.setAttribute("y1", (g.c - g.rb - len).toFixed(2));
          t.setAttribute("opacity", op.toFixed(3));
          const state = lit ? 1 : 0;
          if (s.tickState[i] !== state) {
            s.tickState[i] = state;
            t.setAttribute("stroke", lit ? mix(g.fromRgb, g.toRgb, f) : "currentColor");
          }
        }
      }
      const arc = arcG.current;
      if (arc) arc.setAttribute("opacity", "1");
      // value digits
      const ve = valueEl.current;
      if (ve) ve.style.opacity = (1 - s.ind).toFixed(3);
      const ones = vC % 10;
      const tensPos = Math.floor(vC / 10) + clamp(ones - 9, 0, 1);
      const pos = [vC >= 99 ? 1 : 0, tensPos, ones];
      const vis = [clamp(vC - 99, 0, 1), clamp(vC - 9, 0, 1), 1];
      for (let k = 0; k < 3; k++) {
        const col = cols.current[k];
        const strip = strips.current[k];
        if (!col || !strip) continue;
        col.style.width = (0.62 * vis[k]!).toFixed(3) + "em";
        col.style.opacity = vis[k]!.toFixed(3);
        // hundreds shows a 1; tens/ones roll through the strip
        const p = k === 0 ? 1 : pos[k]!;
        strip.style.transform = "translateY(" + (-(p * 100) / 11).toFixed(3) + "%)";
      }
    };

    const frame = (ts: number) => {
      s.raf = 0;
      const L = live.current;
      const g = cfg.current;
      const dt = Math.min(0.05, Math.max(0.001, (ts - s.last) / 1000));
      s.last = ts;
      const T = L.inView ? L.target : 0;
      let awake = false;
      if (L.reduced) {
        s.v = T;
        s.vel = 0;
        s.ind = L.indeterminate ? 1 : 0;
      } else {
        const w = 5 / Math.max(0.2, g.duration);
        const z = 0.82;
        let rem = dt;
        while (rem > 0) {
          const h = Math.min(rem, 1 / 120);
          s.vel += (w * w * (T - s.v) - 2 * z * w * s.vel) * h;
          s.v += s.vel * h;
          rem -= h;
        }
        if (Math.abs(T - s.v) < 0.005 && Math.abs(s.vel) < 0.02) {
          s.v = T;
          s.vel = 0;
        } else awake = true;
        const it = L.indeterminate ? 1 : 0;
        s.ind += (it - s.ind) * (1 - Math.exp(-dt * 7));
        if (Math.abs(it - s.ind) < 0.002) s.ind = it;
        else awake = true;
        if (L.indeterminate) awake = true;
      }
      s.h += (s.hT - s.h) * (1 - Math.exp(-dt * 9));
      if (Math.abs(s.hT - s.h) < 0.002) s.h = s.hT;
      else awake = true;
      draw(ts);
      if (awake) s.raf = requestAnimationFrame(frame);
    };

    const wake = () => {
      if (s.raf) return;
      s.last = nowMs();
      s.raf = requestAnimationFrame(frame);
    };
    api.current = {
      wake,
      point: (f) => {
        if (f === null) s.hT = 0;
        else {
          s.hT = 1;
          s.hF = f;
        }
        wake();
      },
    };
    draw(nowMs());
    wake();
    return () => {
      mq.removeEventListener("change", onMq);
      io?.disconnect();
      if (s.raf) cancelAnimationFrame(s.raf);
      s.raf = 0;
      api.current = null;
    };
  }, []);

  const onPoint = (e: React.PointerEvent<HTMLDivElement>) => {
    const el = e.currentTarget;
    const r = el.getBoundingClientRect();
    const k = r.width > 0 ? el.offsetWidth / r.width : 1;
    const dx = (e.clientX - r.left) * k - el.offsetWidth / 2;
    const dy = (e.clientY - r.top) * k - el.offsetHeight / 2;
    const a = Math.atan2(dx, -dy) / TAU;
    api.current?.point(((a % 1) + 1) % 1);
  };
  const onLeave = () => api.current?.point(null);

  // Static gradient segments, drawn once: colour follows the angle around the ring.
  const segs: React.ReactNode[] = [];
  const stepA = TAU / SEGMENTS;
  for (let i = 0; i < SEGMENTS; i++) {
    const a0 = i * stepA - 0.004;
    const a1 = (i + 1) * stepA + 0.004;
    const x0 = r3(c + R * Math.sin(a0));
    const y0 = r3(c - R * Math.cos(a0));
    const x1 = r3(c + R * Math.sin(a1));
    const y1 = r3(c - R * Math.cos(a1));
    segs.push(
      <path
        key={i}
        d={"M" + x0 + " " + y0 + "A" + r3(R) + " " + r3(R) + " 0 0 1 " + x1 + " " + y1}
        fill="none"
        stroke={mix(fromRgb, toRgb, (i + 0.5) / SEGMENTS)}
        strokeWidth={sw}
      />,
    );
  }
  const la = -0.5 * (sw / R) - 0.05;
  const leadD =
    "M" + r3(c + R * Math.sin(la)) + " " + r3(c - R * Math.cos(la)) + "A" + r3(R) + " " + r3(R) + " 0 0 1 " + r3(c + R * Math.sin(0.03)) + " " + r3(c - R * Math.cos(0.03));
  const tickCount = 60;
  const tickEls: React.ReactNode[] = [];
  if (showTicks) {
    for (let i = 0; i < tickCount; i++) {
      tickEls.push(
        <line
          key={i}
          x1={c}
          y1={r3(c - rb - tickLen)}
          x2={c}
          y2={r3(c - rb)}
          stroke="currentColor"
          strokeWidth={1.4}
          strokeLinecap="round"
          opacity={0.16}
          transform={"rotate(" + r3((i * 360) / tickCount) + " " + c + " " + c + ")"}
        />,
      );
    }
  }

  const fs = size * 0.27;
  const name = label ?? "Progress";
  return (
    <div
      ref={rootRef}
      role="progressbar"
      aria-label={name}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={indeterminate ? undefined : Math.round(clamp(target, 0, 100))}
      aria-busy={indeterminate || undefined}
      onPointerMove={onPoint}
      onPointerDown={onPoint}
      onPointerLeave={onLeave}
      onPointerUp={(e) => {
        if (e.pointerType !== "mouse") onLeave();
      }}
      className={"relative inline-flex items-center justify-center " + (className ?? "")}
      style={{ width: size, height: size, touchAction: "manipulation" }}
    >
      <svg width={size} height={size} viewBox={"0 0 " + size + " " + size} aria-hidden className="absolute inset-0 overflow-visible">
        <defs>
          <mask id={maskId} maskUnits="userSpaceOnUse" x={0} y={0} width={size} height={size}>
            <circle
              ref={maskCircle}
              cx={c}
              cy={c}
              r={R}
              fill="none"
              stroke="#fff"
              strokeWidth={sw}
              strokeLinecap="round"
              strokeDasharray={"0 " + r3(C)}
              transform={"rotate(-90 " + c + " " + c + ")"}
              opacity={0}
            />
          </mask>
          <filter id={blurId} filterUnits="userSpaceOnUse" x={-size * 0.3} y={-size * 0.3} width={size * 1.6} height={size * 1.6}>
            <feGaussianBlur stdDeviation={Math.max(2, sw * 0.9)} />
          </filter>
        </defs>
        {showTicks ? (
          <g ref={tickG}>{tickEls}</g>
        ) : (
          <g ref={tickG} />
        )}
        <circle cx={c} cy={c} r={R} fill="none" stroke="currentColor" strokeOpacity={0.12} strokeWidth={sw} />
        <g ref={arcG} className={indeterminate ? "motion-reduce:animate-pulse" : undefined}>
          <g mask={"url(#" + maskId + ")"} filter={"url(#" + blurId + ")"} opacity={0.5}>
            {segs}
          </g>
          <g mask={"url(#" + maskId + ")"}>
            {segs}
            <path ref={leadRef} d={leadD} fill="none" stroke={mix(fromRgb, toRgb, 0)} strokeWidth={sw} />
          </g>
          <circle ref={headHalo} cx={c} cy={c} r={sw} fill={from} filter={"url(#" + blurId + ")"} opacity={0} />
          <circle ref={headCore} cx={c} cy={c} r={sw * 0.2} fill="#fff" opacity={0} />
        </g>
      </svg>
      <div className="pointer-events-none relative flex flex-col items-center justify-center text-center" style={{ fontSize: fs }}>
        <span
          ref={valueEl}
          aria-hidden
          className="inline-flex items-baseline font-semibold leading-none"
          style={{ letterSpacing: "-0.03em", fontFeatureSettings: '"tnum"' }}
        >
          <Column colRef={(el) => { cols.current[0] = el; }} stripRef={(el) => { strips.current[0] = el; }} />
          <Column colRef={(el) => { cols.current[1] = el; }} stripRef={(el) => { strips.current[1] = el; }} />
          <Column colRef={(el) => { cols.current[2] = el; }} stripRef={(el) => { strips.current[2] = el; }} />
          <span style={{ fontSize: "0.5em", opacity: 0.5, marginLeft: "0.08em", fontWeight: 500 }}>%</span>
        </span>
        {label && size >= 96 ? (
          <span
            aria-hidden
            className="mt-[0.35em] uppercase"
            style={{ fontSize: Math.max(9, size * 0.075) + "px", letterSpacing: "0.16em", opacity: 0.5, lineHeight: 1 }}
          >
            {label}
          </span>
        ) : null}
      </div>
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

The ring is drawn as ninety short arcs coloured along the angle, revealed through a round-capped stroke mask, so the gradient is real and the ends stay crisp. A blurred copy underneath gives the glow, and a bright head sits at the leading edge, tinted to the gradient colour at that angle. Sixty tick marks surround the ring and light up behind the arc, brightest near the head, and they swell toward the pointer as it moves around the ring. The value is one number driven by a hand-written spring that settles in roughly the duration you set, so changing the target mid-flight carries its momentum instead of restarting. The digits in the centre are columns that roll continuously with that same number, the tens column turning as the ones wrap. Set indeterminate and the arc grows, shrinks and rotates, blending in and out of a determinate value. The animation loop writes straight to the DOM and sleeps when everything is at rest. Track, ticks and numerals follow currentColor, so it works on light and dark pages; the gradient defaults to red and takes any two hex colours.

Curator’s note

Progress Ring, rebuilt in October: a spring instead of a timed tween, a gradient with a lit head, and no animation library.

Pairs well with

Odometer Counter

text effects
Pro