Ring Chart
Progress towards several targets, as concentric arcs.
'use client';
import { useState } from 'react';
import { RingChart } from '@/components/panelui/ring-chart';
import { Frame } from '@/components/panelui/frame';
const GOALS = [
{ label: 'Move', value: 486, maxValue: 600 },
{ label: 'Exercise', value: 24, maxValue: 30 },
{ label: 'Stand', value: 9, maxValue: 12 },
];
export default function RingChartOverview() {
const [active, setActive] = useState(-1);
const ring = GOALS[active] ?? GOALS[0]!;
return (
<Frame variant="band" className="w-full max-w-sm">
<Frame.Header>
<Frame.Title>Activity</Frame.Title>
<Frame.Action>Today</Frame.Action>
</Frame.Header>
<Frame.Panel className="p-5">
<RingChart data={GOALS} size={208} onActiveIndexChange={setActive}>
<RingChart.Header title={ring.label} value={`${ring.value} of ${ring.maxValue}`} />
<RingChart.Ring index={0} />
<RingChart.Ring index={1} />
<RingChart.Ring index={2} />
<RingChart.Center />
<RingChart.Legend />
</RingChart>
</Frame.Panel>
</Frame>
);
}Use it for a few values that each have their own target: calories, minutes and steps against today's goals, or tasks done against a sprint. Each ring is measured against its own maxValue, so three rings can all be at ninety percent of three unrelated numbers.
The rings do not add up, so the chart has no total. A value past its target fills the ring and stops.
For parts of one whole, use a Pie Chart. For values on one shared scale, use a Bar Chart.
Installation
npx shadcn@latest add https://panelui.dev/r/web/ring-chart.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/ring-chart.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/ring-chart.jsonbunx shadcn@latest add https://panelui.dev/r/web/ring-chart.jsonThe source is copied into components/panelui/, where you can edit it.
Usage
import { RingChart } from "@/components/panelui/ring-chart"<RingChart data={goals} size={208}>
<RingChart.Ring index={0} />
<RingChart.Ring index={1} />
<RingChart.Ring index={2} />
<RingChart.Center />
<RingChart.Legend />
</RingChart>Composition
<RingChart data={…}>
<RingChart.Header />
<RingChart.Ring index={0} />
<RingChart.Center />
<RingChart.Legend />
</RingChart>RingChart.Header— The strip above the rings:title,valueandcaption, with a colour key or your own control on the trailing side.RingChart.Ring— One ring, drawn from the entry atindexindata. The chart draws only the rings it is given a part for.RingChart.Center— The readout in the hole: the outermost ring's value and target, then the same for the ring being read.RingChart.Legend— A swatch, a name and a percentage per ring, under the plot. Each entry is a button that reads its ring.
Examples
Reading from outside
Pass activeIndex and onActiveIndexChange when something else on the page has to stay in step. The callback fires for hovering as well as pressing, and with -1 when the reading ends.
'use client';
import { useState } from 'react';
import { RingChart } from '@/components/panelui/ring-chart';
import { Button } from '@/components/panelui/button';
const GOALS = [
{ label: 'Move', value: 486, maxValue: 600 },
{ label: 'Exercise', value: 24, maxValue: 30 },
{ label: 'Stand', value: 9, maxValue: 12 },
];
export default function RingChartSelecting() {
const [active, setActive] = useState(-1);
return (
<div className="flex w-full max-w-sm flex-col items-start gap-4">
<RingChart data={GOALS} size={208} activeIndex={active} onActiveIndexChange={setActive}>
<RingChart.Ring index={0} />
<RingChart.Ring index={1} />
<RingChart.Ring index={2} />
<RingChart.Center />
<RingChart.Legend />
</RingChart>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setActive(1)}>
Show exercise
</Button>
<Button variant="ghost" onClick={() => setActive(-1)}>
Clear
</Button>
</div>
</div>
);
}Gauge
Set startAngle and endAngle less than a turn apart to leave a gap. The track stops where the arc stops, and the gap does not respond to the pointer.
'use client';
import { RingChart } from '@/components/panelui/ring-chart';
const STORAGE = [{ label: 'Storage', value: 141, maxValue: 200 }];
export default function RingChartGauge() {
return (
<RingChart data={STORAGE} size={208} strokeWidth={16} startAngle={-120} endAngle={120} className="max-w-sm">
<RingChart.Ring index={0} />
<RingChart.Center formatValue={(value) => `${value} GB`} />
</RingChart>
);
}Segments
Set segments when the target is a count of things, such as eight of twelve sessions. Ticks light one at a time and the ends default to square.
'use client';
import { RingChart } from '@/components/panelui/ring-chart';
const SESSIONS = [{ label: 'Sessions', value: 8, maxValue: 12 }];
export default function RingChartSegments() {
return (
<RingChart data={SESSIONS} size={208} strokeWidth={14} className="max-w-sm">
<RingChart.Ring index={0} segments={12} />
<RingChart.Center />
</RingChart>
);
}Header with a key
legend on the header puts the names on its trailing side, which suits two or three short names. The header's value is yours to pass, because the rings have no total.
'use client';
import { RingChart } from '@/components/panelui/ring-chart';
const GOALS = [
{ label: 'Protein', value: 92, maxValue: 120 },
{ label: 'Fibre', value: 18, maxValue: 30 },
];
export default function RingChartHeaderDemo() {
return (
<RingChart data={GOALS} size={180} className="max-w-sm">
<RingChart.Header title="Today" value="1,640 kcal" caption="Goal 2,100" legend />
<RingChart.Ring index={0} />
<RingChart.Ring index={1} />
<RingChart.Center />
</RingChart>
);
}Custom centre
Give RingChart.Center a function to draw the middle yourself. It receives the ring being read, or null when there is none.
'use client';
import { RingChart } from '@/components/panelui/ring-chart';
import { Stat } from '@/components/panelui/stat';
const GOALS = [
{ label: 'Move', value: 486, maxValue: 600 },
{ label: 'Exercise', value: 24, maxValue: 30 },
{ label: 'Stand', value: 9, maxValue: 12 },
];
export default function RingChartCenterDemo() {
return (
<RingChart data={GOALS} size={208} className="max-w-sm">
<RingChart.Ring index={0} />
<RingChart.Ring index={1} />
<RingChart.Ring index={2} />
<RingChart.Center>
{(ring) => (
<>
<Stat.Label>{ring ? ring.label : 'Today'}</Stat.Label>
<Stat.Value size="sm">{ring ? ring.value : '3 goals'}</Stat.Value>
</>
)}
</RingChart.Center>
</RingChart>
);
}Changing data
When data changes, each arc moves to its new length. A ref on the chart has replay(), which sweeps the rings in again from nothing.
'use client';
import { useRef, useState } from 'react';
import { RingChart, type RingChartHandle } from '@/components/panelui/ring-chart';
import { Button } from '@/components/panelui/button';
const MORNING = [
{ label: 'Move', value: 140, maxValue: 600 },
{ label: 'Exercise', value: 6, maxValue: 30 },
];
const EVENING = [
{ label: 'Move', value: 520, maxValue: 600 },
{ label: 'Exercise', value: 27, maxValue: 30 },
];
export default function RingChartChangingData() {
const chart = useRef<RingChartHandle>(null);
const [evening, setEvening] = useState(false);
return (
<div className="flex w-full max-w-sm flex-col items-start gap-4">
<RingChart ref={chart} data={evening ? EVENING : MORNING} size={208}>
<RingChart.Ring index={0} />
<RingChart.Ring index={1} />
<RingChart.Center />
<RingChart.Legend />
</RingChart>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setEvening((current) => !current)}>
{evening ? 'Show morning' : 'Show evening'}
</Button>
<Button variant="ghost" onClick={() => chart.current?.replay()}>
Replay
</Button>
</div>
</div>
);
}API Reference
RingChart
| Prop | Type | Default | Description |
|---|---|---|---|
data | readonly RingDatum[] | — | One entry per ring, outermost first. |
size | number | — | Fixed diameter in pixels. Without it the chart fills its container's width as a square. |
strokeWidth | number | 12 | Thickness of each ring. |
ringGap | number | 6 | Gap between one ring and the next. |
startAngle | number | 0 | Where the arcs begin, in degrees clockwise from twelve o'clock. |
endAngle | number | 360 | Where they end, on the same clock. Anything less than a turn from startAngle leaves a gap and reads as a gauge: startAngle={-90} endAngle={90} is the half circle over the top. |
animationDuration | number | 700 | Milliseconds for the arcs to sweep in. |
activeIndex | number | — | The ring being read. Leave unset to let the chart track it. |
onActiveIndexChange | (index: number) => void | — | Fires when the reading changes, from a hover, a key or a press, with -1 when it ends. |
label | string | — | Names the chart for screen readers. |
labelForDatum | (datum: RingDatum, index: number) => string | — | Writes one ring for screen readers, for a value with a unit. |
RingChart.Ring
| Prop | Type | Default | Description |
|---|---|---|---|
index | number | — | Which entry in data this ring draws. |
color | RingChartColor | — | Which of the theme's roles to draw it in, over the datum's and the palette. |
lineCap | 'round' | 'butt' | — | Rounded ends or square ones. Defaults to round, and to square when the ring is segmented. |
trackOpacity | number | — | Opacity of the track behind the arc. |
segments | number | — | Break the ring into this many ticks, lit one at a time, for a target made of countable things. |
segmentGap | number | — | Gap between one tick and the next, in pixels. |
RingChart.Center
| Prop | Type | Default | Description |
|---|---|---|---|
defaultLabel | string | — | Heading shown when no ring is read. Defaults to the outermost ring's own name. |
formatValue | (value: number, ring: RingDatum | null) => string | — | Format the number under the label. Defaults to a compact number. |
className | string | — | Added to the readout. |
RingChart.Legend
| Prop | Type | Default | Description |
|---|---|---|---|
showValue | boolean | — | Show each ring's percentage of its own target beside its name. |
RingChart.Header
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Small line above the value: what the chart is of. |
value | ReactNode | — | The readout. The largest thing on the card, and the first thing read. |
caption | ReactNode | — | One muted line under the value: a period, a comparison, a target. |
labels | Record<string, string> | — | Prettier names for the rings, keyed by their label. |
legend | boolean | false | Draw a swatch and a name per ring along the trailing edge. |
RingChart, RingChart.Header and RingChart.Legend also take the props of the element they render, and a className. RingChart.Center takes a className for its readout. RingChart.Ring draws into the chart's SVG and takes only the props listed.
Notes
Sizing
The chart takes size as its diameter, or fills its container's width as a square without it. strokeWidth and ringGap stay in pixels at any size, so a small chart fits fewer rings. An inner ring that runs out of room stays at its minimum radius and overlaps the one outside it.
The plot draws in the browser after it has been measured, so it is not in server-rendered HTML.
Colour
color on a datum or on RingChart.Ring names a role in the theme, and the part's wins. Without either, rings take the five chart colours in order.
Reading a ring
Hovering a ring reads it. A click or a tap holds the reading until you press that ring again or press outside the rings. A touch drag does not read rings, so the page can still scroll.
useRingChart gives the ring being read to anything rendered inside the chart.
Keyboard
The plot takes focus. The arrow keys, Home and End move between rings, Enter or Space holds the reading, and Escape clears it. Moving to another ring lets go of a held reading. The legend's entries are buttons, reached with Tab.
Screen readers
Each ring is given as text beside the drawing, as its name and percentage, and a ring read with the keyboard is announced. label names the chart and labelForDatum writes a ring, for values with a unit.
Reduced motion
With reduced motion set, the rings appear at their values without sweeping, and a data change lands in one step.
Server Components
RingChart is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read RingChart.Header off the import, so each part is also exported by name: RingChartHeader is RingChart.Header, and so on for the others.
import { RingChart, RingChartHeader, RingChartRing, RingChartCenter, RingChartLegend } from "@/components/panelui/ring-chart"