Skip to main content

Circular Progress

SVG ring indicator for both determinate progress values and indeterminate loading states.

Quick start
import { CircularProgress } from "mates-ui";

// Determinate — shows 65 % arc fill
CircularProgress({ value: 65 });

// Indeterminate — continuous spinning ring
CircularProgress();

Sizes

import { html, renderApp } from 'mates';
import { CircularProgress, Text } from 'mates-ui';

const App = () => () => html`
<x-row gap="var(--md-space-6)" align="center" wrap>
<x-col align="center" gap="var(--md-space-2)">
${CircularProgress({ size: "sm", value: 65 })}
${Text("sm · det.", { type: "label-sm", color: "muted" })}
</x-col>
<x-col align="center" gap="var(--md-space-2)">
${CircularProgress({ size: "md", value: 65 })}
${Text("md · det.", { type: "label-sm", color: "muted" })}
</x-col>
<x-col align="center" gap="var(--md-space-2)">
${CircularProgress({ size: "lg", value: 65 })}
${Text("lg · det.", { type: "label-sm", color: "muted" })}
</x-col>
<x-col align="center" gap="var(--md-space-2)">
${CircularProgress({ size: "sm" })}
${Text("sm · indet.", { type: "label-sm", color: "muted" })}
</x-col>
<x-col align="center" gap="var(--md-space-2)">
${CircularProgress({ size: "md" })}
${Text("md · indet.", { type: "label-sm", color: "muted" })}
</x-col>
<x-col align="center" gap="var(--md-space-2)">
${CircularProgress({ size: "lg" })}
${Text("lg · indet.", { type: "label-sm", color: "muted" })}
</x-col>
</x-row>
`;

renderApp(App, document.getElementById('app'));

Determinate values

import { html, renderApp } from 'mates';
import { CircularProgress, Text } from 'mates-ui';

const App = () => () => html`
<x-row gap="var(--md-space-6)" align="center">
${[0, 25, 50, 75, 100].map((v) => html`
<x-col align="center" gap="var(--md-space-2)">
${CircularProgress({ value: v, size: "md" })}
${Text(v + "%", { type: "label-sm", color: "muted" })}
</x-col>
`)}
</x-row>
`;

renderApp(App, document.getElementById('app'));

Indeterminate

import { html, renderApp } from 'mates';
import { CircularProgress, Text } from 'mates-ui';

const App = () => () => html`
<div style="display:flex;align-items:flex-start;gap:var(--md-space-6);
padding:var(--md-space-5);border-radius:var(--md-radius-lg);
border:0.06rem solid var(--md-color-border);
background:var(--md-color-surface-variant);">
<x-col align="center" gap="var(--md-space-2)" style="flex:1;">
${CircularProgress({ size: "lg", label: "Loading profile" })}
${Text("Profile", { type: "label-sm", color: "muted" })}
</x-col>
<x-col align="center" gap="var(--md-space-2)" style="flex:1;">
${CircularProgress({ size: "lg", label: "Loading activity" })}
${Text("Activity", { type: "label-sm", color: "muted" })}
</x-col>
<x-col align="center" gap="var(--md-space-2)" style="flex:1;">
${CircularProgress({ size: "lg", label: "Loading stats" })}
${Text("Stats", { type: "label-sm", color: "muted" })}
</x-col>
</div>
`;

renderApp(App, document.getElementById('app'));

Interactive

import { html, atom, renderApp } from 'mates';
import { CircularProgress, Text, OutlinedButton, FilledButton } from 'mates-ui';

const App = () => {
const progress = atom(65);
return () => html`
<x-col align="center" gap="var(--md-space-4)">
${CircularProgress({ value: progress(), size: "lg" })}
${Text(`${progress()}%`, { type: "title-lg" })}
<x-row gap="var(--md-space-3)">
${OutlinedButton("− 10", { onClick: () => progress.set((v) => Math.max(0, v - 10)), disabled: progress() <= 0 })}
${FilledButton("+10", { onClick: () => progress.set((v) => Math.min(100, v + 10)), disabled: progress() >= 100 })}
</x-row>
</x-col>
`;
};

renderApp(App, document.getElementById('app'));

Color override

import { html, renderApp } from 'mates';
import { CircularProgress, Text } from 'mates-ui';

const COLORS = [
{ label: "Teal", value: 70, track: "#b2dfdb", fill: "#00897b" },
{ label: "Amber", value: 45, track: "#ffe082", fill: "#f9a825" },
{ label: "Rose", value: 88, track: "#fce4ec", fill: "#e91e63" },
{ label: "Indigo · indet.", value: undefined, track: "#e8eaf6", fill: "#3949ab" },
];

const App = () => () => html`
<x-row gap="var(--md-space-6)" align="center" wrap>
${COLORS.map(({ label, value, track, fill }) => html`
<x-col align="center" gap="var(--md-space-2)">
${CircularProgress({ value, size: "lg", style: { "--md-cp-track": track, "--md-cp-fill": fill } })}
${Text(label, { type: "label-sm", color: "muted" })}
</x-col>
`)}
</x-row>
`;

renderApp(App, document.getElementById('app'));

CircularProgress

import { CircularProgress } from "mates-ui";
Prop Type Default Description
attr AttrMap Extra HTML attributes forwarded to the root element.
classes string | string[] Extra root classes — joined and appended after built-in classes.
data DataAttrMap Root `data-*` map (`testId` → `data-testid`). `testId` prop / `attr` win over `data`.
id string Root `id` shorthand (`attr.id` wins). Form native-control `id` props are unchanged.
label string "Loading" aria-label for the progressbar role.
on OnEventMap DOM event handlers on the root element.
size "xs" | "sm" | "md" | "lg" "md" Diameter of the ring.
style StyleMap Root inline CSS (`style` prop).
style StyleMap Inline CSS on the root element.
testId string Selector for testing (data-testid attribute).
value number 0–100. Omit for indeterminate spinning animation.

CSS tokens

Token Role
--md-cp-fill Arc fill color (determinate progress)
--md-cp-track Ring background / track color

Caveats

  • Omitting `value` switches the component to indeterminate mode — the ring spins continuously with a CSS animation.
  • `value` is clamped visually to 0–100; values outside this range are not validated at the type level.
  • The root element carries `role="progressbar"` with `aria-valuenow`, `aria-valuemin`, and `aria-valuemax` automatically set in determinate mode.
  • Use `styles` with CSS custom properties rather than targeting internal SVG nodes, which are implementation details.