Feedback

Progress ring

Circular progress with the value in the centre, or a spinning arc when the end is unknown. Three sizes.

Built on
role=progressbar<svg>pathLengthprefers-reduced-motion
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
2.1 kB
Dependencies
React
72%Storage
Syncing
Try its options

Loading its options.

Code
import { ProgressRing } from "@/components/equip/progress-ring";

export default function Demo() {
  return (
    <div style={{ display: "flex", alignItems: "flex-start", gap: 24 }}>
      <ProgressRing label="Storage" value={72} showLabel />
      <ProgressRing label="Syncing" showLabel />
    </div>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="display:flex;align-items:flex-start;gap:24px">
  <div role="progressbar" aria-label="Storage" aria-valuemin="0" aria-valuemax="100" aria-valuenow="72" class="eq-progress-ring" data-size="m" data-tone="neutral" data-state="determinate" style="--_percent:72">
    <span class="eq-progress-ring-dial">
      <svg class="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
        <circle class="eq-progress-ring-track" cx="20" cy="20" r="17" />
        <circle class="eq-progress-ring-arc" cx="20" cy="20" r="17" pathLength="100" stroke-dasharray="200 200" />
      </svg>
      <span class="eq-progress-ring-value">72%</span>
    </span>
    <span class="eq-progress-ring-label">Storage</span>
  </div>
  <div role="progressbar" aria-label="Syncing" class="eq-progress-ring" data-size="m" data-tone="neutral" data-state="indeterminate" style="--_percent:0">
    <span class="eq-progress-ring-dial">
      <svg class="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
        <circle class="eq-progress-ring-track" cx="20" cy="20" r="17" />
        <circle class="eq-progress-ring-arc" cx="20" cy="20" r="17" pathLength="100" stroke-dasharray="28 100" />
      </svg>
    </span>
    <span class="eq-progress-ring-label">Syncing</span>
  </div>
</div>

Examples

Sizes

s fits beside a row of text, m a card, l a page header. The centre text scales with the ring.

40%
65%
90%
Code
import { ProgressRing } from "@/components/equip/progress-ring";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 24 }}>
      <ProgressRing size="s" label="Profile complete" value={40} />
      <ProgressRing size="m" label="Profile complete" value={65} />
      <ProgressRing size="l" label="Profile complete" value={90} />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:24px">
  <div role="progressbar" aria-label="Profile complete" aria-valuemin="0" aria-valuemax="100" aria-valuenow="40" class="eq-progress-ring" data-size="s" data-tone="neutral" data-state="determinate" style="--_percent:40">
    <span class="eq-progress-ring-dial">
      <svg class="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
        <circle class="eq-progress-ring-track" cx="20" cy="20" r="17" />
        <circle class="eq-progress-ring-arc" cx="20" cy="20" r="17" pathLength="100" stroke-dasharray="200 200" />
      </svg>
      <span class="eq-progress-ring-value">40%</span>
    </span>
  </div>
  <div role="progressbar" aria-label="Profile complete" aria-valuemin="0" aria-valuemax="100" aria-valuenow="65" class="eq-progress-ring" data-size="m" data-tone="neutral" data-state="determinate" style="--_percent:65">
    <span class="eq-progress-ring-dial">
      <svg class="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
        <circle class="eq-progress-ring-track" cx="20" cy="20" r="17" />
        <circle class="eq-progress-ring-arc" cx="20" cy="20" r="17" pathLength="100" stroke-dasharray="200 200" />
      </svg>
      <span class="eq-progress-ring-value">65%</span>
    </span>
  </div>
  <div role="progressbar" aria-label="Profile complete" aria-valuemin="0" aria-valuemax="100" aria-valuenow="90" class="eq-progress-ring" data-size="l" data-tone="neutral" data-state="determinate" style="--_percent:90">
    <span class="eq-progress-ring-dial">
      <svg class="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
        <circle class="eq-progress-ring-track" cx="20" cy="20" r="17" />
        <circle class="eq-progress-ring-arc" cx="20" cy="20" r="17" pathLength="100" stroke-dasharray="200 200" />
      </svg>
      <span class="eq-progress-ring-value">90%</span>
    </span>
  </div>
</div>

Tones and custom text

A string valueText replaces the percentage in the centre and is read as aria-valuetext, so say it in full words if the centre is terse.

412Tests passed
18/20Seats used
97%API quota
Code
import { ProgressRing } from "@/components/equip/progress-ring";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", gap: 24 }}>
      <ProgressRing tone="ok" label="Tests passed" value={412} max={412} valueText="412" showLabel />
      <ProgressRing tone="warn" label="Seats used" value={18} max={20} valueText="18/20" showLabel />
      <ProgressRing tone="danger" label="API quota" value={97} showLabel />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;align-items:flex-start;gap:24px">
  <div role="progressbar" aria-label="Tests passed" aria-valuemin="0" aria-valuemax="412" aria-valuenow="412" aria-valuetext="412" class="eq-progress-ring" data-size="m" data-tone="ok" data-state="determinate" style="--_percent:100">
    <span class="eq-progress-ring-dial">
      <svg class="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
        <circle class="eq-progress-ring-track" cx="20" cy="20" r="17" />
        <circle class="eq-progress-ring-arc" cx="20" cy="20" r="17" pathLength="100" stroke-dasharray="200 200" />
      </svg>
      <span class="eq-progress-ring-value">412</span>
    </span>
    <span class="eq-progress-ring-label">Tests passed</span>
  </div>
  <div role="progressbar" aria-label="Seats used" aria-valuemin="0" aria-valuemax="20" aria-valuenow="18" aria-valuetext="18/20" class="eq-progress-ring" data-size="m" data-tone="warn" data-state="determinate" style="--_percent:90">
    <span class="eq-progress-ring-dial">
      <svg class="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
        <circle class="eq-progress-ring-track" cx="20" cy="20" r="17" />
        <circle class="eq-progress-ring-arc" cx="20" cy="20" r="17" pathLength="100" stroke-dasharray="200 200" />
      </svg>
      <span class="eq-progress-ring-value">18/20</span>
    </span>
    <span class="eq-progress-ring-label">Seats used</span>
  </div>
  <div role="progressbar" aria-label="API quota" aria-valuemin="0" aria-valuemax="100" aria-valuenow="97" class="eq-progress-ring" data-size="m" data-tone="danger" data-state="determinate" style="--_percent:97">
    <span class="eq-progress-ring-dial">
      <svg class="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
        <circle class="eq-progress-ring-track" cx="20" cy="20" r="17" />
        <circle class="eq-progress-ring-arc" cx="20" cy="20" r="17" pathLength="100" stroke-dasharray="200 200" />
      </svg>
      <span class="eq-progress-ring-value">97%</span>
    </span>
    <span class="eq-progress-ring-label">API quota</span>
  </div>
</div>

Named by visible text

Without label, pass aria-labelledby pointing at text already on the page.

3/8
Nightly backup, eu-west-1
3 of 8 volumes copied
Code
import { ProgressRing } from "@/components/equip/progress-ring";

export default function Example() {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 12, width: "100%", maxWidth: 320, fontSize: 13 }}>
      <ProgressRing size="s" aria-labelledby="ring-backup-title" value={3} max={8} valueText="3/8" />
      <div style={{ minWidth: 0 }}>
        <div id="ring-backup-title" style={{ fontWeight: "var(--eq-weight-strong)" }}>Nightly backup, eu-west-1</div>
        <div style={{ color: "var(--eq-ink-2)" }}>3 of 8 volumes copied</div>
      </div>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;align-items:center;gap:12px;width:100%;max-width:320px;font-size:13px">
  <div role="progressbar" aria-valuemin="0" aria-valuemax="8" aria-valuenow="3" aria-valuetext="3/8" aria-labelledby="ring-backup-title" class="eq-progress-ring" data-size="s" data-tone="neutral" data-state="determinate" style="--_percent:37.5">
    <span class="eq-progress-ring-dial">
      <svg class="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
        <circle class="eq-progress-ring-track" cx="20" cy="20" r="17" />
        <circle class="eq-progress-ring-arc" cx="20" cy="20" r="17" pathLength="100" stroke-dasharray="200 200" />
      </svg>
      <span class="eq-progress-ring-value">3/8</span>
    </span>
  </div>
  <div style="min-width:0">
    <div id="ring-backup-title" style="font-weight:var(--eq-weight-strong)">Nightly backup, eu-west-1</div>
    <div style="color:var(--eq-ink-2)">3 of 8 volumes copied</div>
  </div>
</div>

Props

PropTypeDefaultWhat it does
labelstringnoneThe accessible name, such as "Storage used". Required unless you pass aria-labelledby.
valuenumbernoneHow far along, from 0 to max. Leave out for an indeterminate ring.
maxnumber100The value at which the task is complete.
valueTextReactNodenoneShown in the centre instead of the percentage. A string is also used as aria-valuetext.
size"s" | "m" | "l""m"44, 64 or 96px across.
tone"neutral" | "ok" | "warn" | "danger""neutral"Colour of the arc. Neutral is the solid ink.
showLabelbooleanfalseAlso shows the label under the ring.
...restComponentProps<"div">noneEvery native div attribute, including aria-labelledby.

Anatomy

root.eq-progress-ringThe role="progressbar" element; data-state is determinate, empty or indeterminate.
dial.eq-progress-ring-dialStacks the ring and the centre text.
svg.eq-progress-ring-svgHidden from assistive tech. Spins when indeterminate.
track.eq-progress-ring-track
arc.eq-progress-ring-arcIts dash offset is the percent.
value.eq-progress-ring-valueThe centre text.
label.eq-progress-ring-labelShown with showLabel.

Notes

  • aria-valuemin, aria-valuemax and aria-valuenow are set only when determinate; an indeterminate ring is a progressbar with a name and nothing else.
  • Children of a progressbar are not read, so a string valueText goes to aria-valuetext; a ReactNode valueText is visual only.
  • A change of value springs the arc to its new length; the spring can run a little past the value and settles on it.
  • The indeterminate arc turns on its own, as a progress indicator may; under reduced motion it slows to 2.4 seconds instead of stopping.
  • A ring at zero hides its arc, since a round cap would draw a dot.
  • It is not a live region. If finishing matters, say so in a status message as well.
  • Renders on the server with no client JavaScript.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/progress-ring.tsx75 lines
import type { ComponentProps, CSSProperties, ReactNode } from "react";
import "./progress-ring.css";

type Naming =
  /** Names the progress bar for screen readers, such as "Storage used". */
  | { label: string }
  /** Or point at visible text that names it. */
  | { label?: undefined; "aria-labelledby": string };

export type ProgressRingProps = Omit<ComponentProps<"div">, "children"> &
  Naming & {
    /** How far along, from 0 to max. Leave out for an indeterminate ring. */
    value?: number;
    max?: number;
    /** Shown in the centre instead of the percentage. A string is also used as aria-valuetext. */
    valueText?: ReactNode;
    /** 44, 64 or 96px across. */
    size?: "s" | "m" | "l";
    /** Colour of the arc. Neutral is the solid ink. */
    tone?: "neutral" | "ok" | "warn" | "danger";
    /** Also shows the label under the ring. */
    showLabel?: boolean;
  };

export function ProgressRing({
  label,
  value,
  max = 100,
  valueText,
  size = "m",
  tone = "neutral",
  showLabel = false,
  className,
  style,
  ...rest
}: ProgressRingProps) {
  const determinate = value !== undefined && Number.isFinite(value);
  const top = max > 0 ? max : 100;
  const now = determinate ? Math.min(Math.max(value, 0), top) : 0;
  const percent = (now / top) * 100;
  const centre = valueText ?? (determinate ? `${Math.round(percent)}%` : null);

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={determinate ? 0 : undefined}
      aria-valuemax={determinate ? top : undefined}
      aria-valuenow={determinate ? now : undefined}
      aria-valuetext={typeof valueText === "string" ? valueText : undefined}
      {...rest}
      className={["eq-progress-ring", className].filter(Boolean).join(" ")}
      data-size={size}
      data-tone={tone}
      data-state={determinate ? (percent === 0 ? "empty" : "determinate") : "indeterminate"}
      style={{ "--_percent": percent, ...style } as CSSProperties}
    >
      <span className="eq-progress-ring-dial">
        <svg className="eq-progress-ring-svg" viewBox="0 0 40 40" fill="none" aria-hidden="true">
          <circle className="eq-progress-ring-track" cx="20" cy="20" r="17" />
          <circle
            className="eq-progress-ring-arc"
            cx="20"
            cy="20"
            r="17"
            pathLength="100"
            strokeDasharray={determinate ? "200 200" : "28 100"}
          />
        </svg>
        {centre != null && <span className="eq-progress-ring-value">{centre}</span>}
      </span>
      {showLabel && label && <span className="eq-progress-ring-label">{label}</span>}
    </div>
  );
}
components/equip/progress-ring.css133 lines
@layer equip {
  .eq-progress-ring {
    --_size: 64px;
    --_stroke: 3;
    --_tone: var(--eq-solid);

    display: inline-flex;
    flex-direction: column;
    align-items: center;
    gap: var(--eq-space-2);
    max-width: 100%;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
    vertical-align: middle;
  }

  .eq-progress-ring[data-size="s"] {
    --_size: 44px;
    --_stroke: 3.5;
  }

  .eq-progress-ring[data-size="l"] {
    --_size: 96px;
    --_stroke: 2.5;
  }

  .eq-progress-ring[data-tone="ok"] {
    --_tone: var(--eq-ok);
  }

  .eq-progress-ring[data-tone="warn"] {
    --_tone: var(--eq-warn);
  }

  .eq-progress-ring[data-tone="danger"] {
    --_tone: var(--eq-danger);
  }

  .eq-progress-ring-dial {
    display: grid;
    place-items: center;
    flex: none;
    width: var(--_size);
    height: var(--_size);
  }

  .eq-progress-ring-svg {
    grid-area: 1 / 1;
    display: block;
    width: 100%;
    height: 100%;
    /* Start the arc at twelve o'clock. */
    rotate: -90deg;
    stroke-width: var(--_stroke);
    stroke-linecap: round;
  }

  .eq-progress-ring-track {
    stroke: var(--eq-tint-strong);
  }

  /* Dash 200, gap 200 on a path 100 long: the visible arc is exactly the percent, and a
     spring that runs past 100 or below 0 never opens a notch at either end. */
  .eq-progress-ring-arc {
    stroke: var(--_tone);
    stroke-dashoffset: calc((200 - var(--_percent, 0)) * 1px);
    transition:
      stroke-dashoffset var(--eq-dur-move) var(--eq-spring),
      stroke var(--eq-dur) var(--eq-ease),
      opacity var(--eq-dur) var(--eq-ease);
  }

  /* A round cap would draw a dot at zero. */
  .eq-progress-ring[data-state="empty"] .eq-progress-ring-arc {
    opacity: 0;
  }

  .eq-progress-ring[data-state="indeterminate"] .eq-progress-ring-arc {
    stroke-dashoffset: 0;
  }

  .eq-progress-ring[data-state="indeterminate"] .eq-progress-ring-svg {
    animation: eq-progress-ring-spin 700ms linear infinite;
  }

  @keyframes eq-progress-ring-spin {
    from {
      rotate: -90deg;
    }

    to {
      rotate: 270deg;
    }
  }

  /* The one motion allowed to run on its own. It slows down rather than stopping,
     because a still arc would read as finished. A literal duration, since --eq-dur is 0 here. */
  @media (prefers-reduced-motion: reduce) {
    .eq-progress-ring[data-state="indeterminate"] .eq-progress-ring-svg {
      animation-duration: 2.4s;
    }
  }

  .eq-progress-ring-value {
    grid-area: 1 / 1;
    max-width: calc(var(--_size) - var(--eq-space-2));
    overflow: hidden;
    font-size: var(--eq-text-m);
    font-weight: var(--eq-weight-strong);
    font-variant-numeric: tabular-nums;
    line-height: 1;
    text-align: center;
    white-space: nowrap;
    text-overflow: ellipsis;
  }

  .eq-progress-ring[data-size="s"] .eq-progress-ring-value {
    font-size: var(--eq-text-xs);
    letter-spacing: -0.02em;
  }

  .eq-progress-ring[data-size="l"] .eq-progress-ring-value {
    font-size: calc(var(--eq-text-l) * 1.25);
    letter-spacing: -0.01em;
  }

  .eq-progress-ring-label {
    max-width: 100%;
    color: var(--eq-ink-2);
    text-align: center;
    overflow-wrap: anywhere;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index