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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | The accessible name, such as "Storage used". Required unless you pass aria-labelledby. |
value | number | none | How far along, from 0 to max. Leave out for an indeterminate ring. |
max | number | 100 | The value at which the task is complete. |
valueText | ReactNode | none | Shown 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. |
showLabel | boolean | false | Also shows the label under the ring. |
...rest | ComponentProps<"div"> | none | Every native div attribute, including aria-labelledby. |
Anatomy
| root | .eq-progress-ring | The role="progressbar" element; data-state is determinate, empty or indeterminate. |
|---|---|---|
| dial | .eq-progress-ring-dial | Stacks the ring and the centre text. |
| svg | .eq-progress-ring-svg | Hidden from assistive tech. Spins when indeterminate. |
| track | .eq-progress-ring-track | |
| arc | .eq-progress-ring-arc | Its dash offset is the percent. |
| value | .eq-progress-ring-value | The centre text. |
| label | .eq-progress-ring-label | Shown 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;
}
}