Progress ring
A circular progress indicator drawn with one circle and two numbers.
npx shadcn@latest add https://ui.goodtabs.co/r/progress-ring.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/progress-ring
70%
A 72px ring at 70 percent with the value in the centre.
Showing the working
70%
- r = (24 - 2) / 2 = 11
- 2πr = 69.12
- offset at 70% = 20.73
A 24px ring beside the numbers that draw it.
Quarters
25%
50%
75%
100%
The same ring at 25, 50, 75 and 100 percent.
Usage
import { ProgressRing } from "@/components/ui/progress-ring"
<ProgressRing label="Upload progress" value={70} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Accessible name for the progressbar. | |
| value | number | Current value. | |
| max | number | 100 | The value that means done. |
| size | number | 64 | Outer diameter in px. |
| stroke | number | 4 | Stroke width in px. |
| children | ReactNode | Centre content. Defaults to the percentage. |
Source
progress-ring/progress-ring.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
type ProgressRingProps = Omit<React.ComponentProps<"div">, "role"> & {
/** Accessible name for the progressbar. */
label: string;
/** Current value. */
value: number;
/** The value that means done. */
max?: number;
/** Outer diameter in px. */
size?: number;
/** Stroke width in px. The radius is (size - stroke) / 2 so the stroke stays inside the box. */
stroke?: number;
};
/**
* A progress ring drawn with one circle. dasharray is the circumference 2πr and
* dashoffset is 2πr(1 - p), so the ring fills clockwise from the top. Children sit in the
* centre.
*/
export function ProgressRing({
label,
value,
max = 100,
size = 64,
stroke = 4,
className,
children,
...props
}: ProgressRingProps) {
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const p = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn("relative inline-grid place-items-center", className)}
style={{ width: size, height: size }}
{...props}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-foreground/12" />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c * (1 - p)}
className="stroke-foreground transition-[stroke-dashoffset] duration-200 ease-out motion-reduce:transition-none"
/>
</svg>
<div className="absolute inset-0 grid place-items-center font-mono text-xs text-foreground tabular-nums">
{children ?? `${Math.round(p * 100)}%`}
</div>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/progress-ring/prompt.md, or the Copy AI prompt button above.
When to use
- Compact progress in a card, avatar or list row
- Goals and quotas where the share of a whole matters
- Anywhere a bar is too wide
When not to use
- Indeterminate loading, use a spinner or a progress bar
- Several values compared side by side, use a bar chart
Agent notes
- Server component. The ring starts at the top and fills clockwise
- The radius is (size - stroke) / 2 so the stroke never clips the box
- Colours come from foreground tones; wrap it in text-brand only if you add a mark yourself
- The offset eases over 200ms and the transition is off under reduced motion
The maths
- Radius r = (size - stroke) / 2, so a 24px ring with a 2px stroke has r = 11.
- Circumference 2πr = 69.12 is the dasharray.
- Dashoffset 2πr × (1 - p) = 69.12 × 0.3 = 20.73 at 70 percent.