# Donut Chart

> SVG donut chart with focusable segments, legend, and a center label that updates on hover or focus.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/donut-chart
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/donut-chart.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/donut-chart-demo.tsx"
import { DonutChart } from "@/components/donut-chart"

const traffic = [
  { label: "Organic search", value: 4820 },
  { label: "Direct", value: 3150 },
  { label: "Referral", value: 1760 },
  { label: "Social", value: 1240 },
  { label: "Email", value: 690 },
]

export default function DonutChartDemo() {
  return (
    <div className="w-full max-w-xl rounded-2xl border bg-card p-6 shadow-sm">
      <p className="mb-5 text-sm text-muted-foreground">Traffic sources, last 30 days</p>
      <DonutChart data={traffic} title="Traffic sources" totalLabel="Visits" />
    </div>
  )
}
```

## Source

```tsx title="components/donut-chart.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type DonutTone = "emerald" | "sky" | "violet" | "amber" | "rose" | "primary"

export interface DonutDatum {
  label: string
  value: number
  tone?: DonutTone
}

export interface DonutChartProps extends Omit<React.ComponentProps<"div">, "children"> {
  data: DonutDatum[]
  /** Label shown under the total in the centre */
  totalLabel?: string
  title?: string
}

const stroke: Record<DonutTone, string> = {
  emerald: "stroke-emerald-500 dark:stroke-emerald-400",
  sky: "stroke-sky-500 dark:stroke-sky-400",
  violet: "stroke-violet-500 dark:stroke-violet-400",
  amber: "stroke-amber-500 dark:stroke-amber-400",
  rose: "stroke-rose-500 dark:stroke-rose-400",
  primary: "stroke-primary",
}

const swatch: Record<DonutTone, string> = {
  emerald: "bg-emerald-500 dark:bg-emerald-400",
  sky: "bg-sky-500 dark:bg-sky-400",
  violet: "bg-violet-500 dark:bg-violet-400",
  amber: "bg-amber-500 dark:bg-amber-400",
  rose: "bg-rose-500 dark:bg-rose-400",
  primary: "bg-primary",
}

const order: DonutTone[] = ["emerald", "sky", "violet", "amber", "rose", "primary"]
const number = new Intl.NumberFormat("en-US")

const RADIUS = 38
const CIRC = 2 * Math.PI * RADIUS
const GAP = 1.4

export function DonutChart({ data, totalLabel = "Total", title = "Donut chart", className, ...props }: DonutChartProps) {
  const [active, setActive] = React.useState<number | null>(null)
  const total = data.reduce((sum, d) => sum + Math.max(d.value, 0), 0)
  const safeTotal = total || 1

  const segments = data.map((d, i) => {
    const frac = Math.max(d.value, 0) / safeTotal
    const start = data.slice(0, i).reduce((sum, p) => sum + Math.max(p.value, 0), 0) / safeTotal
    return {
      ...d,
      tone: d.tone ?? order[i % order.length],
      frac,
      percent: Math.round(frac * 1000) / 10,
      start: start * CIRC,
      length: Math.max(frac * CIRC - (data.length > 1 ? GAP : 0), 0),
    }
  })
  const current = active !== null ? segments[active] : null

  return (
    <div className={cn("flex w-full flex-col items-center gap-6 sm:flex-row sm:gap-8", className)} {...props}>
      <div className="relative size-48 shrink-0">
        <svg viewBox="0 0 100 100" className="size-full" role="group" aria-label={title}>
          <circle cx="50" cy="50" r={RADIUS} fill="none" strokeWidth={12} className="stroke-muted" />
          {segments.map((s, i) => (
            <circle
              key={s.label}
              cx="50"
              cy="50"
              r={RADIUS}
              fill="none"
              role="img"
              tabIndex={0}
              aria-label={`${s.label}: ${number.format(s.value)}, ${s.percent}%`}
              strokeWidth={active === i ? 15 : 12}
              strokeDasharray={`${s.length} ${CIRC - s.length}`}
              strokeDashoffset={-(s.start + (data.length > 1 ? GAP / 2 : 0))}
              transform="rotate(-90 50 50)"
              className={cn(
                "cursor-pointer outline-none transition-[stroke-width,opacity] duration-200",
                stroke[s.tone],
                active !== null && active !== i && "opacity-40"
              )}
              onPointerEnter={() => setActive(i)}
              onPointerLeave={() => setActive((cur) => (cur === i ? null : cur))}
              onFocus={() => setActive(i)}
              onBlur={() => setActive((cur) => (cur === i ? null : cur))}
            />
          ))}
        </svg>
        <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
          <span className="text-2xl font-semibold tracking-tight tabular-nums">
            {number.format(current ? current.value : total)}
          </span>
          <span className="max-w-24 truncate text-xs text-muted-foreground">
            {current ? `${current.label} · ${current.percent}%` : totalLabel}
          </span>
        </div>
      </div>
      <ul className="grid w-full min-w-0 flex-1 gap-1">
        {segments.map((s, i) => (
          <li
            key={s.label}
            onPointerEnter={() => setActive(i)}
            onPointerLeave={() => setActive((cur) => (cur === i ? null : cur))}
            className={cn(
              "flex items-center gap-3 rounded-lg px-2.5 py-1.5 text-sm transition-colors",
              active === i && "bg-muted"
            )}
          >
            <span className={cn("size-2.5 shrink-0 rounded-full", swatch[s.tone])} aria-hidden />
            <span className="min-w-0 flex-1 truncate">{s.label}</span>
            <span className="text-muted-foreground tabular-nums">{number.format(s.value)}</span>
            <span className="w-12 text-right font-medium tabular-nums">{s.percent}%</span>
          </li>
        ))}
      </ul>
    </div>
  )
}
```
