Radial progress

Free · MIT

Activity-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.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/radial-progress.json

The 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

Use it for
  • 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.
Reach for something else
  • 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 needUse
Three goals, activity stylerings={[outer, middle, inner]} with legend="end"
Single goal, number in the middlerings={[one]} legend="none"
Money on one ring, counts on anotherring.formatValue per ring
Your own center contentcenter={<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

PropTypeDefault
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.
number160
thickness
Ring thickness in px.
number12
gap
Space between rings in px.
number4
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—
localestring"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.
number1.2
labels
Text for built-in controls and screen readers; pass translations here.
Partial<RadialProgressLabels>—

Also accepts every <div> attribute.

Accessibility

TabFocuses the rings.
Arrow keysCycles the active ring; the center shows its percentage and label.
EscapeClears 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