Radial progress
Free · MITActivity-style concentric goal rings that fill from 12 o'clock on view, spring to new values, dim the others when one is pointed at, and roll the center percentage.
Installation
$ npx shadcn@latest add https://goodui.dev/r/radial-progress.jsonThe file lands in components/goodui/radial-progress.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/radial-progress works too.
Usage
import { RadialProgress } from "@/components/goodui/radial-progress"
export function TodayGoals() {
return (
<RadialProgress
title="Today"
size={180}
thickness={14}
rings={[
{ label: "Bookings", value: 29, max: 40 },
{ label: "Revenue", value: 2140, max: 3000, formatValue: (v, max) => `$${v} / $${max}` },
{ label: "Reviews", value: 6, max: 10 },
]}
/>
)
}When to use
- A few goals at a glance: bookings, revenue and reviews against today's targets.
- One goal ring with a rolling percentage in the middle (monthly target, utilization).
- Values that update live (a new booking lands): rings spring to the new fill.
- Shares of a whole: use donut-chart.
- A value inside bands (healthy / watch / critical): use gauge.
- A long list of goals: rings stop being readable past four; use meter rows.
| If you need | Use |
|---|---|
| Three goals, activity style | rings={[outer, middle, inner]} with legend="end" |
| Single goal, number in the middle | rings={[one]} legend="none" |
| Money on one ring, counts on another | ring.formatValue per ring |
| Your own center content | center={<Icon />} or center={null} |
Examples
Single goal ring
<RadialProgress rings={[{ label: "Monthly target", value: 78, color: "var(--chart-2)" }]} size={112} thickness={10} legend="none" />Translated
<RadialProgress rings={rings} locale="de-DE" labels={{ legend: "Ziele", ring: (label, value, max, pct) => `${label}: ${value} von ${max}, ${pct}` }} />API reference
| Prop | Type | Default |
|---|---|---|
| rings* Rings from the outside in. One ring makes a goal ring; several make activity-style concentric rings. | RadialRing[] | — |
| title Name under the center number, used by screen readers too. | React.ReactNode | — |
| size Diameter in px. | number | 160 |
| thickness Ring thickness in px. | number | 12 |
| gap Space between rings in px. | number | 4 |
| center Replaces the rolling percentage in the middle. Pass null to leave the center empty. | React.ReactNode | — |
| legend Legend placement with each ring's value. | "end" | "bottom" | "none" | "end" |
| format Intl.NumberFormat options for the values. | Intl.NumberFormatOptions | — |
| locale | string | "en-US" |
| formatValue Formats "value / max" for the legend and screen readers. | (value: number, max: number) => string | — |
| duration Seconds the first ring takes to fill; inner rings start 0.12 s later each. | number | 1.2 |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<RadialProgressLabels> | — |
Also accepts every <div> attribute.
Accessibility
| Tab | Focuses the rings. |
| Arrow keys | Cycles the active ring; the center shows its percentage and label. |
| Escape | Clears the active ring. |
- The rings are role="group" with aria-roledescription="progress rings" and a summary that reads every ring ("Bookings: 29 of 40, 73%; …").
- The active ring is announced through a polite live region.
- The legend lists label and value / max for each ring in text, so nothing depends on color.
- Built-in strings (ring, summary, legend) go through labels; per-ring money formatting goes through ring.formatValue.
Motion
- On first view each ring fills from 12 o'clock over duration (1.2 s, ease [0.16, 1, 0.3, 1]), inner rings starting 0.12 s after the one outside them; the center percentage rolls up with NumberFlow.
- Later value changes spring to the new fill (stiffness 120, damping 24).
- Hovering a ring or its legend row dims the others to 35% (track to 50%) over 0.2 s.
- Reduced motion: rings appear filled, changes are instant.
- Rings are stroked circles with pathLength normalization; no per-frame geometry.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- rings are listed from the outside in; max defaults to 100. Values above max fill the ring completely (no lap-over).
- Colors default to --chart-1 … --chart-5 by ring order.
- size and thickness are px; with many rings keep thickness × rings well under size / 2 or the center disappears.
- center={null} removes the center readout; center={<node>} replaces it.
FAQ
Can a ring go past 100%?
It fills completely; the legend still shows the real value (e.g. 46 / 40). A lap-over effect is not drawn.
How do I show an icon instead of the percentage?
Pass center={<Flame className="size-6" />}.
Related
SVG ring or 270° gauge whose arc springs to the value via stroke-dashoffset, with a rolling percentage or custom label in the center.
Donut or pie whose slices sweep in clockwise, re-share the ring when a legend item is toggled, push out on hover and roll the center total to the slice under the pointer.
Semi-circular gauge with Core-tone bands whose needle springs to the value with a small overshoot (or an arc that fills), a rolling readout and a band label pill, exposed as a meter.
Labeled usage meter like "SMS credits 7,420 / 10,000" whose fill springs to the new value and shifts to amber and rose near the limit, with optional per-seat segments.