← All components

ProgressRing

An arc that sweeps round from twelve o'clock, in the scale colour of its own progress.

64

Weekly goal

64 of 100 points

The idea

Worn the way an activity ring is on a watch face. The arc takes the scale colour at its position, so a full ring is the same colour every other on-target mark on the page is. It sweeps in the first time it is seen, and when the value moves it re-sweeps from where it was rather than starting over.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

ProgressRing — usage5 lines
import { ProgressRing } from "mihirmodi-ui";

<ProgressRing value={64} size={56} label="Weekly goal">
  <span className="text-[13px] font-medium">64</span>
</ProgressRing>

Props

PropNotes
value / maxnumberThe reading and the full circle.
size / strokenumberOuter diameter and arc weight.
labelstringThe accessible name.
childrenReactNodeIn the middle — the figure, usually.
good"high" | "low"Flip the scale.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/data/ProgressRing.tsx62 lines
"use client";

import type { ReactNode } from "react";
import { scaleColor, type Good } from "../../lib/scale.js";
import { useDrawIn } from "../../lib/useDrawIn.js";

export type ProgressRingProps = {
  value: number;
  max?: number;
  size?: number;
  stroke?: number;
  good?: Good;
  /** The accessible name. */
  label: string;
  /** In the middle of the ring — the figure, usually. */
  children?: ReactNode;
};

/**
 * A progress arc, the way an activity ring wears it on a watch face: the arc
 * takes the scale colour at its own position, so a full ring is the same colour
 * every other on-target mark on the page is. It sweeps round from twelve o'clock
 * the first time it is seen, and re-sweeps from where it was when the value moves.
 */
export function ProgressRing({ value, max = 100, size = 40, stroke = 3.5, good = "high", label, children }: ProgressRingProps) {
  const { ref, drawn, animate } = useDrawIn<HTMLDivElement>();
  const r = (size - stroke) / 2;
  const c = 2 * Math.PI * r;
  const t = Math.max(0, Math.min(1, value / max));
  const shown = drawn ? t : 0;
  return (
    <div
      ref={ref}
      role="meter"
      aria-valuenow={Math.round(value)}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-label={label}
      className="relative inline-flex items-center justify-center"
      style={{ width: size, height: size }}
    >
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden className="absolute inset-0 -rotate-90">
        <circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} style={{ stroke: "var(--color-gray-alpha-300)" }} />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={c}
          strokeDashoffset={c * (1 - shown)}
          style={{
            stroke: scaleColor(good === "low" ? 1 - t : t),
            transition: animate ? "stroke-dashoffset 900ms cubic-bezier(0.16,1,0.3,1), stroke 600ms ease-out" : undefined,
          }}
        />
      </svg>
      {children && <span className="relative">{children}</span>}
    </div>
  );
}
packages/ui/src/lib/scale.ts54 lines
/**
 * The value scale every chart reads from.
 *
 * A quantity is a position between low and high, and its mark takes the scale
 * colour at that position — so the same value is the same colour in a bar, a
 * dot, a ring and a meter anywhere on the page. `good="low"` flips it for
 * metrics where less is better. A comparison series stays in the flat context
 * colour, so the subject and its reference never compete.
 *
 * The colours are mixed in CSS rather than computed here, so a theme flip (or
 * your own redeclared stops) re-colours every mark without a re-render.
 */

export type Good = "high" | "low";

export const SCALE = { low: "var(--scale-low)", mid: "var(--scale-mid)", high: "var(--scale-high)" } as const;
export const CONTEXT = "var(--chart-context)";
export const GRID = "var(--chart-grid)";

const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);

/** The colour at `t` ∈ [0, 1] on the low → mid → high scale, as a CSS value. */
export function scaleColor(t: number) {
  const u = clamp01(t);
  return u < 0.5
    ? `color-mix(in oklab, ${SCALE.low}, ${SCALE.mid} ${(u * 200).toFixed(1)}%)`
    : `color-mix(in oklab, ${SCALE.mid}, ${SCALE.high} ${((u - 0.5) * 200).toFixed(1)}%)`;
}

/** Where `v` sits between `min` and `max`, flipped when low is good. */
export function scaleT(v: number, min: number, max: number, good: Good = "high") {
  const t = max === min ? 0.5 : (v - min) / (max - min);
  return good === "low" ? 1 - t : t;
}

/** The scale's two ends, in the order a gradient should run for `good`. */
export const ends = (good: Good = "high") => (good === "low" ? [SCALE.high, SCALE.low] : [SCALE.low, SCALE.high]);

/** A round axis maximum above `v`: 1, 2, 2.5 or 5 times a power of ten. */
export function niceMax(v: number) {
  if (v <= 0) return 1;
  const p = Math.pow(10, Math.floor(Math.log10(v)));
  const n = v / p;
  return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * p;
}

export type Format = (v: number) => string;

/** A number with at most `digits` decimals, in the reader's locale — unless you pass your own `format`. */
export function formatter(digits = 0, format?: Format): Format {
  if (format) return format;
  const nf = new Intl.NumberFormat(undefined, { maximumFractionDigits: digits });
  return (v) => nf.format(v);
}