Skip to content
Star on GitHub
Charts and tablesDonut chart
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

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

$npx designme add donut-chart
0hAgent time
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add donut-chart

React

$npx designme add donut-chart --react

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

02

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

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>
  );
}
04

Props

Everything DonutChart takes.

PropTypeWhat it does
segmentsrequiredDonutChartSegment[]
labelstringUnder the total in the middle, like "Agent time"
unitstringAfter every value, like "h" in "14.2h"
format(n: number) => stringWrites a value. Defaults to one decimal, like 14.2
activenumber | nullIndex of the highlighted segment, or null. Leave it out to let hovering a segment or its row highlight it
defaultActivenumber | null
onActiveChange(index: number | null) => void
classNamestring
05

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'); });
});
06

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 active with the segment's index (or null for none) and update it from your own state; onActiveChange tells you when hovering changes it.
How do I show money or percentages instead of hours?
Pass format to write each value and unit for the text after it. Both apply to the middle and to every legend row.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Line chartTwo series over time with a hover readout and toggles.
Bar chartWeekly agent runs, split by outcome, with a range switch.
Funnel chartSignup to paid, with the biggest drop called out by the agent.
Top pagesMost visited pages with share of traffic and a quick sort.
Health scoreA design system score made of a few checks you can open.
Activity heatmapWhen the agent and the team were busy, by day and hour.
Stat cardsFour headline numbers with sparklines and a period switch.
Account tableCustomer accounts with health, plan and an agent note per row.