Skip to content
UI Kit
Accent color

Donut chart

Part to whole as a ring or a half gauge, the total in the middle, a legend with values and shares.

One of 7 components in Charts

3 variants

Variants

Donut with legend

Three slices, the total in the center. Pointing or arrow keys swap the center to the slice.

Half gauge

A half ring against a capacity, the free rest stays as a quiet track.

Compact pair

Two small rings side by side, legends below, shares only.

Details

What Donut chart brings along, read from the registry and the source.

Category
Charts
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/donut-chart.tsx
Brings along
utilskitchart
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The DonutChartProps interface from components/ui/donut-chart.tsx, with the comments from the code. Also takes every prop of ChartA11y.

  • datarequired
    DonutSlice[]

    Up to four slices read best. Fold the rest into "Other".

  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }

  • variantoptional
    'donut' | 'half'

    donut: full ring. half: a gauge from left to right.

  • maxoptional
    number

    The whole ring stands for this value. The rest stays as a quiet track (gauges, budgets). Default: the sum.

  • centerLabeloptional
    string

    Small text in the middle, under the value. Default: "Total".

  • centerValueoptional
    string

    Big text in the middle. Default: the formatted sum.

  • sizeoptional
    number

    Diameter in px. It shrinks with the container.

  • thicknessoptional
    number

    Ring width in px

  • legendoptional
    'side' | 'bottom' | false

    Legend next to the ring (side), below it (bottom) or none

  • percentoptional
    boolean

    Share in percent next to each legend value

Accessibility

Read from the source of the component.

  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 147 lines
components/ui/donut-chart.tsx
'use client';

import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartLegend, CountUp, ChartRoot, ChartSurface, ChartTable, arcPath, numberFormatter, seriesColor, useActive, useEntrance, useIntlLocale, type ChartA11y } from '@/components/ui/chart';

export interface DonutSlice {
  label: string;
  value: number;
  /** Any CSS color. Default: the slot color in order (accent, grey, two accent tones). */
  color?: string;
}

export interface DonutChartProps extends ChartA11y {
  /** Up to four slices read best. Fold the rest into "Other". */
  data: DonutSlice[];
  /** Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 } */
  format?: Intl.NumberFormatOptions;
  /** donut: full ring. half: a gauge from left to right. */
  variant?: 'donut' | 'half';
  /** The whole ring stands for this value. The rest stays as a quiet track (gauges, budgets). Default: the sum. */
  max?: number;
  /** Small text in the middle, under the value. Default: "Total". */
  centerLabel?: string;
  /** Big text in the middle. Default: the formatted sum. */
  centerValue?: string;
  /** Diameter in px. It shrinks with the container. */
  size?: number;
  /** Ring width in px */
  thickness?: number;
  /** Legend next to the ring (side), below it (bottom) or none */
  legend?: 'side' | 'bottom' | false;
  /** Share in percent next to each legend value */
  percent?: boolean;
  className?: string;
}

/** Time the whole ring takes to run on, in ms. The slices run one after another, each as long as its share, so the ring reads as one stroke. */
const RING_MS = 900;

/**
 * Part to whole as a ring or a half gauge, with the total in the middle.
 *   <DonutChart label="Revenue by channel" data={[{ label: 'Online', value: 254310 }, …]} format={{ style: 'currency', currency: 'EUR' }} />
 */
export function DonutChart({ data, format, variant = 'donut', max, centerLabel, centerValue, size = 200, thickness, legend = 'side', percent = true, label, summary, labels, animate = true, className }: DonutChartProps) {
  const intl = useIntlLocale();
  const t = useTexts(CHART_TEXTS, labels);
  const fmt = numberFormatter(intl, format);
  const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const { active, setActive, onKeyDown, onFocus, onBlur } = useActive(data.length);

  const half = variant === 'half';
  const sum = data.reduce((a, d) => a + Math.max(0, d.value), 0);
  const whole = Math.max(max ?? sum, sum) || 1;
  const sweep = half ? 180 : 360;
  const start = half ? -90 : 0;
  const w = thickness ?? round(size * (half ? 0.12 : 0.11), 0);
  const r = round(size / 2 - w / 2 - 3);
  const cx = round(size / 2);
  const cy = half ? round(size / 2) : cx;
  const height = half ? round(size / 2 + w / 2 + 3) : size;
  // The 2px surface gap at the middle of the ring, as an angle
  const gapDeg = round((2 / r) * (180 / Math.PI), 3);
  const showTrack = half || sum < whole;

  let acc = 0;
  const arcs = data.map((d, i) => {
    const a = start + (acc / whole) * sweep;
    acc += Math.max(0, d.value);
    const b = start + (acc / whole) * sweep;
    const gap = data.length > 1 || showTrack ? gapDeg : 0;
    return { a: round(a + gap / 2, 3), b: round(b - gap / 2, 3), from: (a - start) / sweep, part: Math.max(0, d.value) / whole, d, i, color: seriesColor(i, d.color) };
  });

  // Whole percents that add up to 100 (largest remainder), so the legend never shows 101 %
  const exact = data.map((d) => (Math.max(0, d.value) / whole) * 100);
  const whole100 = exact.map(Math.floor);
  const missing = Math.round(exact.reduce((x, y) => x + y, 0)) - whole100.reduce((x, y) => x + y, 0);
  exact
    .map((v, i) => [v - Math.floor(v), i] as const)
    .sort((p, q) => q[0] - p[0])
    .slice(0, Math.max(0, missing))
    .forEach(([, i]) => (whole100[i] += 1));
  const shareAt = (i: number) => pct(whole100[i] / 100);
  const act = active !== null ? data[active] : null;
  // The total counts up with the ring, a hovered slice shows its own value at once
  const mid: React.ReactNode = act ? fmt(act.value) : centerValue ?? <CountUp value={sum} format={fmt} anim={anim} duration={900} />;
  const sub = act ? act.label : centerLabel ?? t.total;
  const live = act ? `${act.label}, ${fmt(act.value)}, ${shareAt(active!)}` : '';
  const auto = data.map((d, i) => `${d.label} ${shareAt(i)}`).join(', ');

  return (
    <ChartRoot anim={anim} rootRef={rootRef} className={className}>
      <div className={cn('flex flex-col items-center gap-6', legend === 'side' && 'sm:flex-row sm:gap-10')}>
        <ChartSurface
          label={label}
          summary={summary ?? auto}
          labels={labels}
          live={live}
          table={<ChartTable caption={label} head={['', label, '%']} rows={data.map((d, i) => [d.label, fmt(d.value), shareAt(i)])} />}
          onKeyDown={onKeyDown}
          onFocus={onFocus}
          onBlur={onBlur}
          onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
          className={cn('w-full shrink-0', half ? 'rounded-t-full' : 'rounded-full')}
          style={{ maxWidth: size }}
        >
          <div className="relative mx-auto" style={{ maxWidth: size }}>
            <svg viewBox={`0 0 ${size} ${height}`} width="100%" aria-hidden className="block overflow-visible">
              {showTrack && <path d={arcPath(cx, cy, r, start, start + sweep)} fill="none" stroke="var(--kitch-empty)" strokeWidth={w} />}
              {arcs.map(({ a, b, from, part, d, i, color }) =>
                b > a ? (
                  <path
                    key={d.label}
                    d={arcPath(cx, cy, r, a, b)}
                    fill="none"
                    stroke={color}
                    strokeWidth={active === i ? w + 6 : w}
                    pathLength={1}
                    opacity={active !== null && active !== i ? 0.25 : 1}
                    onPointerEnter={() => setActive(i)}
                    onPointerDown={() => setActive(i)}
                    className={cn('transition-[stroke-width,opacity] duration-200 motion-reduce:transition-none', anim && 'kitch-draw')}
                    style={anim ? ({ '--kitch-d': `${round(from * RING_MS, 0)}ms`, '--kitch-t': `${round(Math.max(160, part * RING_MS), 0)}ms`, '--kitch-e': i === arcs.length - 1 ? 'var(--ease-kit)' : 'linear' } as React.CSSProperties) : undefined}
                  />
                ) : null,
              )}
            </svg>
            <div className={cn('pointer-events-none absolute inset-x-0 flex flex-col items-center text-center', half ? 'bottom-0' : 'inset-y-0 justify-center')}>
              <span className={cn('font-semibold leading-tight tracking-tight text-foreground', size >= 180 ? 'text-[22px] sm:text-[24px]' : 'text-[18px]')}>{mid}</span>
              <span className="mt-0.5 max-w-[70%] truncate text-[12px] text-muted-foreground">{sub}</span>
            </div>
          </div>
        </ChartSurface>
        {legend && (
          <ChartLegend
            layout="list"
            className={cn('w-full', legend === 'side' ? 'sm:max-w-[18rem]' : 'max-w-[22rem]')}
            active={active}
            onActive={setActive}
            items={data.map((d, i) => ({ label: d.label, color: seriesColor(i, d.color), shape: 'rect', value: fmt(d.value), note: percent ? shareAt(i) : undefined }))}
          />
        )}
      </div>
    </ChartRoot>
  );
}

Frequent questions

How do I install Donut chart?

With aiw add @ai-website/donut-chart. The command puts components/ui/donut-chart.tsx into your project, the code is yours from then on. It brings along chart. It needs no further npm packages.

Which variants does Donut chart have?

3 variants, Donut with legend, Half gauge and Compact pair.

Can an AI assistant build with Donut chart?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Donut chart with aiw_components and install it with aiw_add @ai-website/donut-chart. Name the variant in your prompt, for example variant="half", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard