Charts

Radial Progress Chart

Concentric progress rings — one ring per metric against its own target, a summary number in the hole, and over-target values capped at one lap instead of drawing through.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { PolarAngleAxis, RadialBar, RadialBarChart, Sector } from "recharts"

import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import type {
  ChartRadialProgressData,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-radial-progress.json

Prompt

Build a React + TypeScript + Tailwind "ChartRadialProgress" card on the shadcn
chart primitives (ChartContainer/ChartTooltip over recharts RadialBarChart)
with zod.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    items: { key, label, value >= 0, target > 0, unit? }[];
    centerValue?: string; centerCaption?: string }.
- Each item is one ring measured against its OWN target, so percent =
  value / target * 100 and rings with different units still share a chart.
- items is capped at 5 (one per chart token) and refined: ready needs at
  least one ring, keys must be unique. Non-ready states carry [].
- Component props = z.infer of the schema, plus onRetry?: () => void and
  className. No hand-written parallel interface.

Behavior
- Rings are concentric: item[0] is the outermost. recharts lays data[0] at
  the innermost band, so the array is reversed before it is plotted while
  the legend keeps the authored order.
- Two numbers per ring: pct (the truth, uncapped) and plotted =
  min(pct, 100) (what the chart is allowed to see). The legend, tooltip and
  aria-label read pct; only the RadialBar dataKey reads plotted.
- That split is the whole trick. recharts WIDENS an explicit PolarAngleAxis
  domain to swallow out-of-domain data, so feeding it 137 does not overflow
  the ring — it redefines a full lap as 137% and silently shrinks every
  sibling: measured, a 100% ring drew 262.8° instead of 360° as soon as a
  137% ring shared the chart, and a 0.4% ring collapsed from 1.44° to
  0.14° next to a 1000% one. Capping before the axis sees the value is what
  keeps the whole chart honest, not just the one ring.
- A capped ring must not be mistaken for an exactly-100% ring, so an
  over-target ring is drawn with two hairline gaps near its tip (the
  "this bar is truncated" convention) and the legend prints the true
  uncapped percent (137%), never the capped one.
- 0% draws no arc at all, on purpose: recharts' `background` track renders
  for every item, so a zero ring reads as a visible empty ring instead of
  disappearing. Percentages between 0 and 1 print as "<1%" so a sliver of
  arc is never labelled "0%".
- percent is computed defensively (target <= 0 or non-finite => 0, negative
  value => 0) because the runtime feed can be worse than the type.
- Band thickness follows the ring count (min(18, max(8, 46 / count))) so
  five rings still leave a gap between them.
- Center: centerValue or the mean of the ring percentages, with a short
  caption under it. It is pointer-events-none so it never eats hover.
- Tooltip rows are looked up in the ring array by key, not read off
  item.payload: recharts overwrites `value` on the sector's payload copy
  with the plotted (capped) number, so a naive tooltip quotes
  "100 / 1,000" for a ring that actually did 1,370.
- The four states are first-class branches in one bg-card panel:
  - loading: concentric pulsing skeleton rings + legend lines, aria-hidden.
  - empty: two outline rings + "Nothing tracked yet" + one explaining line.
  - error: message + a "Try again" button rendered only when onRetry exists.
  - ready: rings + center stat + legend list.

Rendering & styling
- Colors come only from the chart tokens: ring i uses var(--chart-{i+1}) for
  the arc fill, the legend swatch and the tooltip swatch — one formula,
  three consumers. The muted track comes from the chart primitive's own
  `.recharts-radial-bar-background-sector` rule.
- Every arc carries a 1px var(--foreground) outline at 0.35 opacity and the
  track gets the same hairline at 0.25. The default chart palette is
  monochrome and identical in both themes, so one end of it always lands
  near the surface behind it: measured on a white card, the lightest token's
  arc goes 1.48:1 -> 2.75:1 with the outline, and the muted empty track goes
  1.09:1 -> 1.62:1. That outline is what makes a 0% ring still read as a
  ring, without inventing a color outside the token set.
- PolarAngleAxis type="number" domain={[0, 100]} with tick={false} fixes the
  angular scale, so a ring at 68% is 68% of a lap and not "the biggest one".
  startAngle 90 / endAngle -270 = one clockwise lap starting at 12 o'clock.
- Accessibility: the ChartContainer gets role="img" plus an aria-label that
  spells out every ring ("Team Aurora 109% of target, over target, ring
  capped at full") — recharts paths read as nothing otherwise. role="img" is
  children-presentational, so nothing focusable may live inside it: recharts
  makes its own <svg> a tab stop with role="application", which under
  role="img" becomes a nameless focus stop, so the chart is given
  tabIndex={-1} role="presentation" and the legend below carries the
  keyboard- and screen-reader-reachable copy of the numbers.
- recharts animation is switched off under prefers-reduced-motion via
  useSyncExternalStore over matchMedia (server snapshot false).
- Panel: rounded-xl border bg-card p-6; the chart is capped at max-w-60 and
  centered; numbers are tabular-nums; cn() merges className.

Customization levers
- Ring count: 1 ring is a legit "single goal" card (the caption falls back
  to "of target"); 5 is the ceiling because the palette has 5 tokens. Past
  that, split into two cards or make rings selectable.
- Over-target policy: swap capping for wrapping by drawing the excess
  (pct - 100) as a thinner inset second lap inside the same band. Whatever
  you choose, plotted must stay <= the axis domain, or recharts re-scales
  every ring; and the excess must stay explicitly marked, never an
  unannounced second lap.
- Aggregate: the center defaults to the mean of the ring percentages; pass
  centerValue for a weighted total (sum(value)/sum(target)), a headline
  count, or a currency figure, and centerCaption to relabel it.
- Geometry: innerRadius 46% sets the hole the center stat lives in — widen
  it for longer center text, shrink it for chunkier rings; startAngle 90 /
  endAngle -270 can become 180/0 for a half-donut gauge layout.
- Density: max-w-60 + p-6 suits a dashboard grid; drop to max-w-40 and p-4
  for a compact tile, or move the legend beside the rings with a two-column
  grid on sm and up.
- Legend: it doubles as the data table for the chart — drop the value/target
  column for a percent-only look, or add a delta column against last period.

Concepts

  • Per-ring target — each ring carries its own target, so "1,180 of 1,000 deals" and "43 of 100 accounts" sit in one chart without a shared axis; the ring shows the ratio, the legend shows the raw pair.
  • Capped before the axis, not after — an explicit domain={[0, 100]} is not a ceiling: recharts widens it to fit whatever the data holds, so one 137% ring redefines a full lap and drags every sibling down with it (a 100% ring measured 262.8° instead of 360°). Capping the plotted number keeps the axis fixed; the excess lives on in the legend text.
  • Truncation gaps — the two hairline cuts near a full ring's tip are the "this bar is truncated" convention: they are what tells a 137% ring apart from a 100% one at a glance, before you read any number.
  • Empty track as the zero state — the muted background sector is drawn for every item whether or not it has an arc, so a 0% metric stays present in the stack instead of vanishing and silently re-ranking the rings.
  • Outline over palette luck — the default chart palette is monochrome and identical in both themes, so one end of it always lands near the surface behind it. A foreground hairline on every arc and track makes the extent readable (measured 1.48:1 → 2.75:1 for the lightest arc on a white card) without hardcoding a color.
  • role="img" + spoken summary — recharts renders bare <path> elements that a screen reader announces as nothing, and makes its own <svg> a role="application" tab stop that role="img" then strips of meaning. The plot is taken out of the tab order, one summarizing aria-label describes it, and the legend repeats every number as real text.

On This Page