Charts
Donut
A ring showing up to five parts of a whole plus other, with a legend and optional text in the hole.
- Built on
<svg>stroke-dasharraycolor-mix():has()- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.7 kB
- Dependencies
- React
72%
- Backups43%, 31 GB
- Media31%, 22 GB
- Logs17%, 12 GB
- Databases10%, 7 GB
Try its options
Loading its options.
Code
import { Donut } from "@/components/equip/donut";
const storage = [
{ label: "Backups", value: 31 },
{ label: "Media", value: 22 },
{ label: "Logs", value: 12 },
{ label: "Databases", value: 7 },
];
export default function Demo() {
return (
<Donut
data={storage}
label="Storage by type, 72 of 100 GB used"
center="72%"
format={{ style: "unit", unit: "gigabyte" }}
/>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div class="eq-donut" data-legend="shown" style="--_t:12px">
<div class="eq-donut-chart" style="width:96px;height:96px">
<svg class="eq-donut-ring" role="img" aria-label="Storage by type, 72 of 100 GB used: Backups 43%, Media 31%, Logs 17%, Databases 10%" width="96" height="96" viewBox="0 0 96 96">
<g transform="rotate(-90 48 48)">
<circle class="eq-donut-segment" data-i="0" cx="48" cy="48" r="40" stroke-dasharray="106.21 251.327" stroke-dashoffset="-1" style="--_tone:var(--eq-chart-1)" />
<circle class="eq-donut-segment" data-i="1" cx="48" cy="48" r="40" stroke-dasharray="74.794 251.327" stroke-dashoffset="-109.21" style="--_tone:var(--eq-chart-2)" />
<circle class="eq-donut-segment" data-i="2" cx="48" cy="48" r="40" stroke-dasharray="39.888 251.327" stroke-dashoffset="-186.005" style="--_tone:var(--eq-chart-3)" />
<circle class="eq-donut-segment" data-i="3" cx="48" cy="48" r="40" stroke-dasharray="22.435 251.327" stroke-dashoffset="-227.893" style="--_tone:var(--eq-chart-4)" />
</g>
</svg>
<span class="eq-donut-centre">72%</span>
</div>
<ul class="eq-donut-legend" role="list">
<li class="eq-donut-item" data-i="0">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-1)" aria-hidden="true"></span>
<span class="eq-donut-name">Backups</span>
<span class="eq-donut-share">43%</span>
<span class="eq-donut-value">, 31 GB</span>
</li>
<li class="eq-donut-item" data-i="1">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-2)" aria-hidden="true"></span>
<span class="eq-donut-name">Media</span>
<span class="eq-donut-share">31%</span>
<span class="eq-donut-value">, 22 GB</span>
</li>
<li class="eq-donut-item" data-i="2">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-3)" aria-hidden="true"></span>
<span class="eq-donut-name">Logs</span>
<span class="eq-donut-share">17%</span>
<span class="eq-donut-value">, 12 GB</span>
</li>
<li class="eq-donut-item" data-i="3">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-4)" aria-hidden="true"></span>
<span class="eq-donut-name">Databases</span>
<span class="eq-donut-share">10%</span>
<span class="eq-donut-value">, 7 GB</span>
</li>
</ul>
</div>Examples
Folding into other
Past five parts, the smallest are summed into Other, so every remaining step of ink stays tellable apart.
91k lines
- TypeScript53%, 48,210
- Go24%, 21,480
- Python11%, 9,870
- SQL7%, 6,120
- Shell3%, 2,940
- Other3%, 2,410
Code
import { Donut } from "@/components/equip/donut";
const languages = [
{ label: "TypeScript", value: 48210 },
{ label: "Go", value: 21480 },
{ label: "Python", value: 9870 },
{ label: "SQL", value: 6120 },
{ label: "Shell", value: 2940 },
{ label: "Rust", value: 1610 },
{ label: "Dockerfile", value: 420 },
{ label: "HCL", value: 380 },
];
export default function Example() {
return <Donut data={languages} label="Lines of code by language" size={120} center="91k lines" />;
}HTMLworks in any framework, with the part's CSS
<div class="eq-donut" data-legend="shown" style="--_t:15px">
<div class="eq-donut-chart" style="width:120px;height:120px">
<svg class="eq-donut-ring" role="img" aria-label="Lines of code by language: TypeScript 53%, Go 24%, Python 11%, SQL 7%, Shell 3%, Other 3%" width="120" height="120" viewBox="0 0 120 120">
<g transform="rotate(-90 60 60)">
<circle class="eq-donut-segment" data-i="0" cx="60" cy="60" r="50.5" stroke-dasharray="166.044 317.301" stroke-dashoffset="-1" style="--_tone:var(--eq-chart-1)" />
<circle class="eq-donut-segment" data-i="1" cx="60" cy="60" r="50.5" stroke-dasharray="72.872 317.301" stroke-dashoffset="-169.044" style="--_tone:var(--eq-chart-2)" />
<circle class="eq-donut-segment" data-i="2" cx="60" cy="60" r="50.5" stroke-dasharray="32.404 317.301" stroke-dashoffset="-243.917" style="--_tone:var(--eq-chart-3)" />
<circle class="eq-donut-segment" data-i="3" cx="60" cy="60" r="50.5" stroke-dasharray="19.332 317.301" stroke-dashoffset="-278.32" style="--_tone:var(--eq-chart-4)" />
<circle class="eq-donut-segment" data-i="4" cx="60" cy="60" r="50.5" stroke-dasharray="8.248 317.301" stroke-dashoffset="-299.653" style="--_tone:var(--eq-chart-5)" />
<circle class="eq-donut-segment" data-i="5" cx="60" cy="60" r="50.5" stroke-dasharray="6.4 317.301" stroke-dashoffset="-309.9" style="--_tone:color-mix(in srgb, var(--eq-ink) 12%, transparent)" />
</g>
</svg>
<span class="eq-donut-centre">91k lines</span>
</div>
<ul class="eq-donut-legend" role="list">
<li class="eq-donut-item" data-i="0">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-1)" aria-hidden="true"></span>
<span class="eq-donut-name">TypeScript</span>
<span class="eq-donut-share">53%</span>
<span class="eq-donut-value">, 48,210</span>
</li>
<li class="eq-donut-item" data-i="1">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-2)" aria-hidden="true"></span>
<span class="eq-donut-name">Go</span>
<span class="eq-donut-share">24%</span>
<span class="eq-donut-value">, 21,480</span>
</li>
<li class="eq-donut-item" data-i="2">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-3)" aria-hidden="true"></span>
<span class="eq-donut-name">Python</span>
<span class="eq-donut-share">11%</span>
<span class="eq-donut-value">, 9,870</span>
</li>
<li class="eq-donut-item" data-i="3">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-4)" aria-hidden="true"></span>
<span class="eq-donut-name">SQL</span>
<span class="eq-donut-share">7%</span>
<span class="eq-donut-value">, 6,120</span>
</li>
<li class="eq-donut-item" data-i="4">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-5)" aria-hidden="true"></span>
<span class="eq-donut-name">Shell</span>
<span class="eq-donut-share">3%</span>
<span class="eq-donut-value">, 2,940</span>
</li>
<li class="eq-donut-item" data-i="5">
<span class="eq-donut-swatch" style="--_tone:color-mix(in srgb, var(--eq-ink) 12%, transparent)" aria-hidden="true"></span>
<span class="eq-donut-name">Other</span>
<span class="eq-donut-share">3%</span>
<span class="eq-donut-value">, 2,410</span>
</li>
</ul>
</div>Compact, legend off
With showLegend off the legend is visually hidden but still read, so the shares stay reachable as text.
- Passed93%, 412
- Skipped5%, 23
- Failed1%, 6
Code
import { Donut } from "@/components/equip/donut";
export default function Example() {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, fontSize: "var(--eq-text-s)" }}>
<Donut
data={[
{ label: "Passed", value: 412 },
{ label: "Skipped", value: 23 },
{ label: "Failed", value: 6 },
]}
label="Test run 1842: 412 passed, 23 skipped, 6 failed"
size={40}
thickness={6}
showLegend={false}
/>
<span>Test run 1842, 93% passed</span>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;align-items:center;gap:12px;font-size:var(--eq-text-s)">
<div class="eq-donut" data-legend="hidden" style="--_t:6px">
<div class="eq-donut-chart" style="width:40px;height:40px">
<svg class="eq-donut-ring" role="img" aria-label="Test run 1842: 412 passed, 23 skipped, 6 failed: Passed 93%, Skipped 5%, Failed 1%" width="40" height="40" viewBox="0 0 40 40">
<g transform="rotate(-90 20 20)">
<circle class="eq-donut-segment" data-i="0" cx="20" cy="20" r="15" stroke-dasharray="86.05 94.248" stroke-dashoffset="-1" style="--_tone:var(--eq-chart-1)" />
<circle class="eq-donut-segment" data-i="1" cx="20" cy="20" r="15" stroke-dasharray="2.915 94.248" stroke-dashoffset="-89.05" style="--_tone:var(--eq-chart-2)" />
<circle class="eq-donut-segment" data-i="2" cx="20" cy="20" r="15" stroke-dasharray="0.5 94.248" stroke-dashoffset="-93.965" style="--_tone:var(--eq-chart-3)" />
</g>
</svg>
</div>
<ul class="eq-donut-legend" role="list">
<li class="eq-donut-item" data-i="0">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-1)" aria-hidden="true"></span>
<span class="eq-donut-name">Passed</span>
<span class="eq-donut-share">93%</span>
<span class="eq-donut-value">, 412</span>
</li>
<li class="eq-donut-item" data-i="1">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-2)" aria-hidden="true"></span>
<span class="eq-donut-name">Skipped</span>
<span class="eq-donut-share">5%</span>
<span class="eq-donut-value">, 23</span>
</li>
<li class="eq-donut-item" data-i="2">
<span class="eq-donut-swatch" style="--_tone:var(--eq-chart-3)" aria-hidden="true"></span>
<span class="eq-donut-name">Failed</span>
<span class="eq-donut-share">1%</span>
<span class="eq-donut-value">, 6</span>
</li>
</ul>
</div>
<span>Test run 1842, 93% passed</span>
</div>No data yet
With nothing above zero the ring draws an empty track and its label says there is no data.
0
Code
import { Donut } from "@/components/equip/donut";
export default function Example() {
return <Donut data={[{ label: "Invoices paid", value: 0 }]} label="Invoices by status" center="0" />;
}HTMLworks in any framework, with the part's CSS
<div class="eq-donut" data-legend="shown" style="--_t:12px">
<div class="eq-donut-chart" style="width:96px;height:96px">
<svg class="eq-donut-ring" role="img" aria-label="Invoices by status: no data" width="96" height="96" viewBox="0 0 96 96">
<circle class="eq-donut-track" cx="48" cy="48" r="40" />
</svg>
<span class="eq-donut-centre">0</span>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
data | { label: string; value: number }[] | none | The parts, largest first after sorting. Zero and negative values are left out; past five the rest fold into Other. |
label | string | none | Names the chart. Each share is added to it for the accessible name. |
size | number | 96 | Diameter in pixels. |
thickness | number | size / 8 | Ring width in pixels, at least 6. |
center | ReactNode | none | Text in the hole, such as the share used. The earlier name centre is still accepted. |
showLegend | boolean | true | Shows the legend. When off it is visually hidden but still read. |
format | Intl.NumberFormatOptions | none | How each value is written in the legend text. |
locale | string | "en-US" | Passed to Intl.NumberFormat. |
...rest | ComponentProps<"div"> | none | Every native div attribute. |
Anatomy
| root | .eq-donut | |
|---|---|---|
| chart | .eq-donut-chart | Holds the ring and the centre text. |
| ring | .eq-donut-ring | role="img" named with every share. |
| segment | .eq-donut-segment | Ink at a lower strength for each step; thickens with the spring on hover. |
| track | .eq-donut-track | Drawn instead of segments when there is no data. |
| centre | .eq-donut-centre | |
| legend | .eq-donut-legend | |
| item | .eq-donut-item | Hovering it lifts its segment and dims the rest. |
| swatch | .eq-donut-swatch | |
| name | .eq-donut-name | |
| share | .eq-donut-share | |
| value | .eq-donut-value | The formatted value, visually hidden. |
Notes
- Segments step down in strength of the ink, not in hue, and the legend names each one with its share, so no part relies on colour alone.
- Strengths are mixed with transparent, so the ring reads on any surface in any finish.
- The Other label is fixed English text; pass your own folded entry if you need another word.
- Renders on the server with no client JavaScript.
- Linking a legend item to its segment on hover uses :has(); without it each still responds on its own.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/donut.tsx122 lines
import type { CSSProperties, ComponentProps, ReactNode } from "react";
import "./donut.css";
export type DonutDatum = { label: string; value: number };
export type DonutProps = ComponentProps<"div"> & {
/** The parts of the whole. Zero and negative values are left out; past five, the smallest fold into "Other". */
data: DonutDatum[];
/** Names the chart, such as "Storage by type". Each share is added to it. */
label: string;
/** Diameter in pixels. */
size?: number;
/** Ring width in pixels. */
thickness?: number;
/** Text in the hole, such as the share used. */
center?: ReactNode;
/** @deprecated The earlier name of center, still accepted. */
centre?: ReactNode;
/** Shows the legend beside the ring. When off it stays in the page for assistive tech. */
showLegend?: boolean;
/** Formats each value in the legend. */
format?: Intl.NumberFormatOptions;
locale?: string;
};
const STEPS = [100, 70, 48, 32, 20, 12];
const GAP = 2;
export function Donut({
data,
label,
size = 96,
thickness = Math.max(6, Math.round(size / 8)),
center,
centre,
showLegend = true,
format,
locale = "en-US",
className,
style,
...rest
}: DonutProps) {
const number = new Intl.NumberFormat(locale, format);
const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 });
const parts = data.filter((d) => Number.isFinite(d.value) && d.value > 0).sort((a, b) => b.value - a.value);
const rest5 = parts.slice(5);
const slices =
rest5.length > 0
? [...parts.slice(0, 5), { label: "Other", value: rest5.reduce((sum, d) => sum + d.value, 0) }]
: parts;
const total = slices.reduce((sum, d) => sum + d.value, 0);
const c = size / 2;
/* Two pixels of room so a hovered segment can thicken without clipping. */
const r = Math.max(1, (size - thickness) / 2 - 2);
const circumference = 2 * Math.PI * r;
const gap = slices.length > 1 ? GAP : 0;
let start = 0;
const arcs = slices.map((d, i) => {
const share = d.value / total;
const length = Math.max(0.5, share * circumference - gap);
const arc = { ...d, i, share, length, offset: -(start * circumference + gap / 2) };
start += share;
return arc;
});
const summary =
total > 0 ? `${label}: ${arcs.map((a) => `${a.label} ${percent.format(a.share)}`).join(", ")}` : `${label}: no data`;
const tone = (i: number) => (i < 5 ? { "--_tone": `var(--eq-chart-${i + 1})` } : { "--_tone": "color-mix(in srgb, var(--eq-ink) 12%, transparent)" }) as CSSProperties;
return (
<div
{...rest}
className={["eq-donut", className].filter(Boolean).join(" ")}
data-legend={showLegend ? "shown" : "hidden"}
style={{ ...style, "--_t": `${thickness}px` } as CSSProperties}
>
<div className="eq-donut-chart" style={{ width: size, height: size }}>
<svg className="eq-donut-ring" role="img" aria-label={summary} width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{total > 0 ? (
<g transform={`rotate(-90 ${c} ${c})`}>
{arcs.map((a) => (
<circle
key={a.i}
className="eq-donut-segment"
data-i={a.i}
cx={c}
cy={c}
r={r}
strokeDasharray={`${round(a.length)} ${round(circumference)}`}
strokeDashoffset={round(a.offset)}
style={tone(a.i)}
/>
))}
</g>
) : (
<circle className="eq-donut-track" cx={c} cy={c} r={r} />
)}
</svg>
{(center ?? centre) != null && <span className="eq-donut-centre">{center ?? centre}</span>}
</div>
{total > 0 && (
<ul className="eq-donut-legend" role="list">
{arcs.map((a) => (
<li key={a.i} className="eq-donut-item" data-i={a.i}>
<span className="eq-donut-swatch" style={tone(a.i)} aria-hidden="true" />
<span className="eq-donut-name">{a.label}</span>
<span className="eq-donut-share">{percent.format(a.share)}</span>
<span className="eq-donut-value">{`, ${number.format(a.value)}`}</span>
</li>
))}
</ul>
)}
</div>
);
}
function round(n: number) {
return Math.round(n * 1000) / 1000;
}components/equip/donut.css137 lines
@layer equip {
.eq-donut {
display: inline-flex;
align-items: center;
gap: var(--eq-space-4);
font: var(--eq-weight) var(--eq-text-xs) / 1.3 var(--eq-font);
color: var(--eq-ink-2);
}
.eq-donut-chart {
position: relative;
flex: none;
}
.eq-donut-ring {
display: block;
overflow: visible;
}
/* Each part is ink at a lower strength over whatever is behind, so the ring holds in every finish. */
.eq-donut-segment {
fill: none;
stroke: var(--_tone, var(--eq-ink));
stroke-width: var(--_t);
transition:
stroke-width var(--eq-dur-move) var(--eq-spring),
opacity var(--eq-dur) var(--eq-ease);
}
.eq-donut-track {
fill: none;
stroke: var(--eq-tint-strong);
stroke-width: var(--_t);
}
.eq-donut-centre {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: calc(var(--_t) + var(--eq-space-1));
color: var(--eq-ink);
font-size: var(--eq-text-m);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
text-align: center;
line-height: 1.15;
pointer-events: none;
}
.eq-donut-legend {
display: grid;
gap: var(--eq-space-1);
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.eq-donut-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--eq-space-2);
transition: color var(--eq-dur) var(--eq-ease);
}
.eq-donut-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--_tone, var(--eq-ink));
box-shadow: inset 0 0 0 1px var(--eq-line);
}
.eq-donut-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-donut-share {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
}
.eq-donut-value,
.eq-donut[data-legend="hidden"] .eq-donut-legend {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (hover: hover) {
.eq-donut:has(.eq-donut-item:hover, .eq-donut-segment:hover) .eq-donut-segment {
opacity: 0.4;
}
.eq-donut:has(.eq-donut-item[data-i="0"]:hover) .eq-donut-segment[data-i="0"],
.eq-donut:has(.eq-donut-segment[data-i="0"]:hover) .eq-donut-segment[data-i="0"],
.eq-donut:has(.eq-donut-item[data-i="1"]:hover) .eq-donut-segment[data-i="1"],
.eq-donut:has(.eq-donut-segment[data-i="1"]:hover) .eq-donut-segment[data-i="1"],
.eq-donut:has(.eq-donut-item[data-i="2"]:hover) .eq-donut-segment[data-i="2"],
.eq-donut:has(.eq-donut-segment[data-i="2"]:hover) .eq-donut-segment[data-i="2"],
.eq-donut:has(.eq-donut-item[data-i="3"]:hover) .eq-donut-segment[data-i="3"],
.eq-donut:has(.eq-donut-segment[data-i="3"]:hover) .eq-donut-segment[data-i="3"],
.eq-donut:has(.eq-donut-item[data-i="4"]:hover) .eq-donut-segment[data-i="4"],
.eq-donut:has(.eq-donut-segment[data-i="4"]:hover) .eq-donut-segment[data-i="4"],
.eq-donut:has(.eq-donut-item[data-i="5"]:hover) .eq-donut-segment[data-i="5"],
.eq-donut:has(.eq-donut-segment[data-i="5"]:hover) .eq-donut-segment[data-i="5"] {
opacity: 1;
stroke-width: calc(var(--_t) + 4px);
}
.eq-donut:has(.eq-donut-segment[data-i="0"]:hover) .eq-donut-item[data-i="0"],
.eq-donut-item[data-i="0"]:hover,
.eq-donut:has(.eq-donut-segment[data-i="1"]:hover) .eq-donut-item[data-i="1"],
.eq-donut-item[data-i="1"]:hover,
.eq-donut:has(.eq-donut-segment[data-i="2"]:hover) .eq-donut-item[data-i="2"],
.eq-donut-item[data-i="2"]:hover,
.eq-donut:has(.eq-donut-segment[data-i="3"]:hover) .eq-donut-item[data-i="3"],
.eq-donut-item[data-i="3"]:hover,
.eq-donut:has(.eq-donut-segment[data-i="4"]:hover) .eq-donut-item[data-i="4"],
.eq-donut-item[data-i="4"]:hover,
.eq-donut:has(.eq-donut-segment[data-i="5"]:hover) .eq-donut-item[data-i="5"],
.eq-donut-item[data-i="5"]:hover {
color: var(--eq-ink);
}
}
}