Charts and tablesDonut chart
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callDonut chart
Where the agent's time went, by kind of task. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add donut-chartReact
npx designme add donut-chart --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A ring chart that splits one total into parts, such as the hours an agent spent on each kind of task. The total sits in the middle, and a legend beside the ring lists each part with its value and share. Hovering a slice or its legend row highlights both and puts that part's value in the middle.
- When you want to show where an agent's time, cost or effort went.
- When a total breaks into a handful of categories and their shares matter.
- When a small dashboard card needs both the total and the breakdown.
- Draws one arc per entry in
segments, sized by its share of the total, sweeping in one after another. - The middle shows the total, counting up, with
labelunder it; while a part is highlighted it shows that part's value and label instead. - Each legend row shows the value with
unitand its rounded percent of the total. - Hovering a slice or a row highlights both;
activeandonActiveChangelet you drive the highlight from outside. - Colours come from the chart palette in order, or from each segment's
color, andformatsets how values read (one decimal by default).
- The ring's SVG is aria-hidden, so the values are read from the legend rows as text.
- The count-up of the total finishes at once when the user prefers reduced motion.
Usage
Import it and drive it from your own state.
Import
import { DonutChart } from '@/components/designme/donut-chart/DonutChart';Example
The demo above as a React component, 15 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: where the agent's time went.
import { DonutChart } from './DonutChart';
export default function DonutChartDemo() {
return (
<div style={{ width: '100%' }}>
<DonutChart label="Agent time" unit="h" segments={[
{ label: 'Design reviews', value: 14.2 }, { label: 'Code changes', value: 9.6 }, { label: 'Research', value: 6.1 },
{ label: 'Copy edits', value: 3.4 }, { label: 'Other', value: 1.7, color: 'var(--dm-faint)' }
]} />
</div>
);
}
Props
Everything DonutChart takes.
| Prop | Type | What it does |
|---|---|---|
segmentsrequired | DonutChartSegment[] | |
label | string | Under the total in the middle, like "Agent time" |
unit | string | After every value, like "h" in "14.2h" |
format | (n: number) => string | Writes a value. Defaults to one decimal, like 14.2 |
active | number | null | Index of the highlighted segment, or null. Leave it out to let hovering a segment or its row highlight it |
defaultActive | number | null | |
onActiveChange | (index: number | null) => void | |
className | string |
Code
The full source, as the CLI installs it.
DonutChart.tsxReact · 72 lines
'use client';
import { useStagger } from '../lib/chart';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import { enter } from '../lib/motion';
import '../designme.css';
import './donut-chart.css';
export interface DonutChartSegment {
/** Like "Design reviews" */
label: string;
value: number;
/** Defaults to the chart palette, in order */
color?: string;
}
export interface DonutChartProps {
segments: DonutChartSegment[];
/** Under the total in the middle, like "Agent time" */
label?: string;
/** After every value, like "h" in "14.2h" */
unit?: string;
/** Writes a value. Defaults to one decimal, like 14.2 */
format?: (n: number) => string;
/** Index of the highlighted segment, or null. Leave it out to let hovering a segment or its row highlight it */
active?: number | null;
defaultActive?: number | null;
onActiveChange?: (index: number | null) => void;
className?: string;
}
const R = 48, C = 2 * Math.PI * R, GAP = 2;
/** A ring of segments that draw in one by one, with a legend; hovering either one highlights both and shows it in the middle. */
export function DonutChart({ segments, label = 'Total', unit = '', format = n => n.toFixed(1), active, defaultActive = null, onActiveChange, className }: DonutChartProps) {
const [hl, setHl] = useControllable(active, defaultActive, onActiveChange);
const total = segments.reduce((a, s) => a + s.value, 0);
// the arcs sweep in one after another, a moment after the ring appears
const drawn = useStagger(1, segments.length, 90, 100);
const counted = useTween(total, 1200, 0);
const color = (i: number) => segments[i].color ?? `var(--dm-c${i % 6 + 1})`;
const pick = hl != null ? segments[hl] : undefined;
let off = 0;
return (
<div data-dm="" className={cx('dm-dnx dm-panel', className)}>
<div className={cx('dm-dnx-ring', pick && 'dm-hov')}>
<svg viewBox="0 0 120 120" aria-hidden="true">
{segments.map((s, i) => {
const len = s.value / (total || 1) * C, at = off; off += len;
return <circle key={i} className={cx(hl === i && 'dm-hl')} cx="60" cy="60" r={R} style={{ stroke: color(i) }} strokeDasharray={`${drawn[i] ? len - GAP : 0} ${C}`} strokeDashoffset={-at}
onMouseEnter={() => setHl(i)} onMouseLeave={() => setHl(null)} />;
})}
</svg>
<div className="dm-dnx-c">
<b className="dm-num">{format(pick ? pick.value : counted)}{unit}</b>
<span className="dm-muted">{pick ? pick.label : label}</span>
</div>
</div>
<div className="dm-dnx-lg">
{segments.map((s, i) => (
<div key={i} className={cx('dm-dnx-r dm-enter', hl === i && 'dm-hl')} style={enter(150 + i * 60).style} onMouseEnter={() => setHl(i)} onMouseLeave={() => setHl(null)}>
<i style={{ background: color(i) }} />
<span className="dm-t">{s.label}</span>
<span className="dm-v dm-num">{format(s.value)}{unit} · {Math.round(s.value / (total || 1) * 100)}%</span>
</div>
))}
</div>
</div>
);
}
donut-chart.cssReact · 20 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Donut chart ---------- */
.dm-dnx{padding:var(--dm-s-4);display:flex;align-items:center;gap:var(--dm-s-5);flex-wrap:wrap;justify-content:center}
.dm-dnx-ring{position:relative;width:150px;height:150px;flex:none}
.dm-dnx-ring svg{width:100%;height:100%;transform:rotate(-90deg)}
.dm-dnx-ring circle{fill:none;stroke-width:12;transition:stroke-width .35s var(--dm-ease),opacity .3s,stroke-dasharray 1s var(--dm-ease)}
.dm-dnx-ring circle.dm-hl{stroke-width:16}
.dm-dnx-ring.dm-hov circle:not(.dm-hl){opacity:.3}
.dm-dnx-c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none}
.dm-dnx-ring circle{cursor:default}
.dm-dnx-c b{font-size:14px;font-weight:500;letter-spacing:-.02em}
.dm-dnx-c span{font-size:11.5px}
.dm-dnx-lg{display:flex;flex-direction:column;gap:2px;flex:1;min-width:170px}
.dm-dnx-r{display:flex;align-items:center;gap:9px;padding:6px var(--dm-s-2);border-radius:9px;font-size:13px;transition:background .2s;cursor:default}
.dm-dnx-r.dm-hl{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-dnx-r:hover{background:var(--dm-row)}}
.dm-dnx-r i{width:8px;height:8px;border-radius:3px;flex:none}
.dm-dnx-r .dm-t{flex:1}
.dm-dnx-r .dm-v{color:var(--dm-muted);font-size:12px}
donut-chart.htmlHTML · 9 lines
<div class="card" data-c="donut-chart">
<div class="stage-in">
<div class="dnx panel">
<div class="dnx-ring"><svg viewBox="0 0 120 120"></svg><div class="dnx-c"><b class="num dnx-v">0h</b><span class="muted dnx-l">Agent time</span></div></div>
<div class="dnx-lg"></div>
</div>
</div>
</div>
donut-chart.cssHTML · 19 lines
/* ---------- Donut chart ---------- */
.dnx{padding:var(--s-4);display:flex;align-items:center;gap:var(--s-5);flex-wrap:wrap;justify-content:center}
.dnx-ring{position:relative;width:150px;height:150px;flex:none}
.dnx-ring svg{width:100%;height:100%;transform:rotate(-90deg)}
.dnx-ring circle{fill:none;stroke-width:12;transition:stroke-width .35s var(--ease),opacity .3s,stroke-dasharray 1s var(--ease)}
.dnx-ring circle.hl{stroke-width:16}
.dnx-ring.hov circle:not(.hl){opacity:.3}
.dnx-c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none}
.dnx-ring circle{cursor:default}
.dnx-c b{font-size:14px;font-weight:500;letter-spacing:-.02em}
.dnx-c span{font-size:11.5px}
.dnx-lg{display:flex;flex-direction:column;gap:2px;flex:1;min-width:170px}
.dnx-r{display:flex;align-items:center;gap:9px;padding:6px var(--s-2);border-radius:9px;font-size:13px;transition:background .2s;cursor:default}
.dnx-r.hl{background:var(--row)}
@media (hover:hover) and (pointer:fine){.dnx-r:hover{background:var(--row)}}
.dnx-r i{width:8px;height:8px;border-radius:3px;flex:none}
.dnx-r .t{flex:1}
.dnx-r .v{color:var(--muted);font-size:12px}
donut-chart.jsHTML · 16 lines
DM.def('donut-chart', (r, c) => {
const D = [['Design reviews',14.2,'var(--a1)'],['Code changes',9.6,'var(--a2)'],['Research',6.1,'var(--a3)'],['Copy edits',3.4,'var(--a4)'],['Other',1.7,'var(--faint)']];
const tot = D.reduce((a, d) => a + d[1], 0), C = 2 * Math.PI * 48, gap = 2;
const svg = $('svg', r), lg = $('.dnx-lg', r), ring = $('.dnx-ring', r), v = $('.dnx-v', r), l = $('.dnx-l', r);
let off = 0;
svg.innerHTML = D.map((d, i) => { const len = d[1] / tot * C; const s = `<circle cx="60" cy="60" r="48" stroke="${d[2]}" stroke-dasharray="0 ${C}" stroke-dashoffset="${-off}" data-i="${i}" data-len="${len - gap}"/>`; off += len; return s; }).join('');
lg.innerHTML = D.map((d, i) => `<div class="dnx-r" data-i="${i}"><i style="background:${d[2]}"></i><span class="t">${d[0]}</span><span class="v num">${d[1]}h · ${Math.round(d[1] / tot * 100)}%</span></div>`).join('');
const arcs = $$('circle', svg), rows = $$('.dnx-r', lg);
const hl = i => { ring.classList.toggle('hov', i != null); arcs.forEach((a, j) => a.classList.toggle('hl', j === i)); rows.forEach((x, j) => x.classList.toggle('hl', j === i));
if (i == null){ v.textContent = tot.toFixed(1) + 'h'; l.textContent = 'Agent time'; } else { v.textContent = D[i][1] + 'h'; l.textContent = D[i][0]; } };
[...arcs, ...rows].forEach(el => { el.onmouseenter = () => hl(+el.dataset.i); el.onmouseleave = () => hl(null); });
stagger(rows, 60, 150);
v.textContent = '0.0h';
c.later(260, () => { arcs.forEach((a, i) => c.later(i * 90, () => a.setAttribute('stroke-dasharray', `${a.dataset.len} ${C}`))); tween(0, tot, 1200, n => v.textContent = n.toFixed(1) + 'h'); });
});
Questions
What people ask about it.
- How do I grey out an "Other" slice?
- Give that segment its own
color, like 'var(--dm-faint)'. Segments without a color take the next palette colour. - Can I highlight a slice from somewhere else on the page?
- Yes. Pass
activewith the segment's index (or null for none) and update it from your own state;onActiveChangetells you when hovering changes it. - How do I show money or percentages instead of hours?
- Pass
formatto write each value andunitfor the text after it. Both apply to the middle and to every legend row.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.