DonutChart

SVG donut and pie chart with legend, centre label, and arc hover.

Live demo

01

Donut with centre total

The default look: a thin ring leaves a hole for a headline figure. Hover or tab a wedge to raise it.

  • Direct: 42 (42%)
  • Organic search: 31 (31%)
  • Social: 18 (18%)
  • Referral: 9 (9.0%)

Solid pie

Same component, no flag β€” pushing thickness up to the radius collapses the hole into a classic pie.

  • TypeScript: 58 (58%)
  • Svelte: 22 (22%)
  • CSS: 12 (12%)
  • Other: 8 (8.0%)

Interactive playground

Drag the sliders to morph between donut and pie and to widen the slice gaps in real time.

  • Engineering: 120 (46%)
  • Design: 45 (17%)
  • Marketing: 60 (23%)
  • Operations: 35 (13%)

Empty state

With no data the chart degrades to a faint track ring and suppresses the legend.

Implementation

02
DonutChart.svelte
<script lang="ts">
  import DonutChart from '$lib/components/DonutChart.svelte';
​
  const data = [
    { label: 'Direct', value: 45, colour: '#6366f1' },
    { label: 'Search', value: 30, colour: '#10b981' },
    { label: 'Social', value: 25, colour: '#f59e0b' }
  ];
</script>
​
<DonutChart {data} centreLabel="100" centreSub="visits" />

DonutChart stacks N identical SVG <circle> elements that share a centre and radius, then uses stroke-dasharray to reveal only each slice’s share of the circumference and stroke-dashoffset to rotate it into place. No path trigonometry is involved, so a percentage becomes a single multiply. Donut versus pie is one continuous knob: thickness maps to the SVG stroke-width, and a stroke as thick as the radius fills the hole to make a solid pie.

Logic explainer

03

What Does It Do? (Plain English)

DonutChart turns an array of { label, value, colour } into a ring of coloured wedges,
drawn entirely with SVG. It works out each slice's share of the whole, paints it as an arc,
and lists every slice in a legend with its raw value and computed percentage. Hovering or
tab-focusing a wedge raises it slightly and pops a tooltip; in donut mode the empty middle
shows a headline figure (a total, a target, whatever you pass as centreLabel).

The same component renders a pie chart: there is no separate "pie" mode flag. A pie is simply
a donut whose ring is as thick as its radius, so the hole disappears. Set thickness to
half the size (or larger) and you get a solid pie; leave it small and you get a donut.

How It Works (Pseudo-Code)

total = sum(max(0, value) for each slice)

for each slice:
    fraction   = slice.value / total
    arcLength  = fraction * circumference        # of the centre-line circle
    visible    = arcLength - gap                 # shrink so wedges read as separate
    draw a full <circle> with:
        stroke-dasharray  = "visible (circumference - visible)"
        stroke-dashoffset = -cursorSoFar         # rotate this wedge into place
    cursorSoFar += arcLength

rotate the whole <g> by -90deg so slice 0 starts at 12 o'clock

Every wedge is the same full circle; only its dash pattern differs. The dasharray makes
just visible units of stroke paint, and dashoffset slides that painted segment around the
ring so each slice lands after the previous one.

The Core Concept: One Circle, Many Dashes

A naΓ―ve donut draws SVG <path> arcs with trigonometry for each wedge's start/end points.
DonutChart avoids that maths entirely. It stacks N identical <circle> elements, all sharing
the same centre and radius, and uses stroke-dasharray / stroke-dashoffset to reveal a
different slice of each. Because a circle's circumference is a known constant
(2 Γ— Ο€ Γ— r), turning a percentage into a dash length is a single multiply β€” no sin/cos,
no path-string building, no rounding artefacts where arcs meet.

This is also why donut-vs-pie is a continuous knob rather than two code paths: thickness
maps straight to the SVG stroke-width, and a thick enough stroke fills the hole.

CSS Animation Strategy

Raising a slice is a transform: scale(1.06) on the focused/hovered <circle>, with
transform-box: fill-box and transform-origin: center so it scales out from its own
centre rather than the SVG origin. Non-active slices fade to opacity: 0.4 so the raised
wedge stands out. All of this lives in the transition shorthand and is fully removed
under @media (prefers-reduced-motion: reduce), leaving an instant state change.

State Flow Diagram

            hover / focus slice i ──► active = i
                                        β”‚
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β–Ό                           β–Ό                           β–Ό
   slice i scales up            other slices dim           tooltip + centre
   (.donut__seg--active)        (.donut__seg--dim)         show slice i details

            mouseleave / blur ──► active = null ──► all slices return to rest,
                                                    centre shows centreLabel again

Props Reference

Prop Type Default Description
data DonutSlice[] [] Slices, each { label: string; value: number; colour: string }.
thickness number 26 Ring thickness in px. When >= size / 2 the hole closes (pie).
size number 220 Chart width and height in px.
centreLabel string '' Headline shown in the donut hole. Ignored in pie mode.
centreSub string '' Smaller caption beneath the centre label.
showLegend boolean true Render the legend (values + percentages) next to the chart.
gap number 1.5 Gap between slices, as a percent of the circumference.
ariaLabel string auto-generated Override the figure's aria-label; defaults to a slice summary.

Edge Cases

Situation Behaviour
data empty or total === 0 Renders just the faint track circle; legend and tooltip suppressed.
Negative values Clamped to 0 via Math.max(0, value) before totalling.
Very thin slice + large gap Gap is clamped to half the slice so the wedge never vanishes completely.
thickness >= size / 2 Treated as a solid pie; the centre label is hidden (no hole to fill).
Single slice Fills the whole ring (one wedge at 100%).
Duplicate labels Keyed by label + index, so repeats still render distinctly.

Dependencies

Zero external dependencies. Pure Svelte 5 runes, scoped CSS, and inline SVG. No charting
library, no canvas, no icon set.

File Structure

src/lib/components/DonutChart.svelte   β€” component (arc maths, legend, tooltip, a11y)
src/lib/components/DonutChart.md       β€” this explainer (rendered in the page shell)
src/lib/components/DonutChart.test.ts  β€” vitest + @testing-library/svelte coverage
src/routes/donutchart/+page.svelte     β€” demo page (donut, pie, and live variants)

API

04
PropTypeDefaultDescription
dataDonutSlice[][]Slices, each { label, value, colour }.
thicknessnumber26Ring thickness in px; >= size / 2 renders a solid pie.
sizenumber220Chart width and height in px.
centreLabelstring''Headline shown in the donut hole; ignored in pie mode.
centreSubstring''Smaller caption beneath the centre label.
showLegendbooleantrueRender the legend with values and percentages.
gapnumber1.5Gap between slices, as a percent of the circumference.
ariaLabelstringautoOverride the figure's aria-label; defaults to a slice summary.