Web InnoventixFreeCode

Chart Donut

Original · free

SVG donut chart with centre label

byWeb InnoventixReact + Tailwind
chartdonutcharts
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/chart-donut.json
chart-donut.tsx
"use client";

import { useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type Mode = "amount" | "share";

const SEGMENTS = [
  { id: "compute", label: "Compute", value: 20240, change: 6.2, stroke: "stroke-indigo-500 dark:stroke-indigo-400", dot: "bg-indigo-500 dark:bg-indigo-400" },
  { id: "storage", label: "Storage", value: 8680, change: 2.1, stroke: "stroke-violet-500 dark:stroke-violet-400", dot: "bg-violet-500 dark:bg-violet-400" },
  { id: "database", label: "Database", value: 7230, change: -3.4, stroke: "stroke-sky-500 dark:stroke-sky-400", dot: "bg-sky-500 dark:bg-sky-400" },
  { id: "network", label: "Networking", value: 5780, change: 11.8, stroke: "stroke-emerald-500 dark:stroke-emerald-400", dot: "bg-emerald-500 dark:bg-emerald-400" },
  { id: "analytics", label: "Analytics", value: 3860, change: 0.9, stroke: "stroke-amber-500 dark:stroke-amber-400", dot: "bg-amber-500 dark:bg-amber-400" },
  { id: "monitoring", label: "Monitoring", value: 2410, change: -5.0, stroke: "stroke-rose-500 dark:stroke-rose-400", dot: "bg-rose-500 dark:bg-rose-400" },
] as const;

interface Arc {
  id: string;
  label: string;
  value: number;
  change: number;
  stroke: string;
  dot: string;
  fraction: number;
  startAngle: number;
  dash: number;
}

const TOTAL = SEGMENTS.reduce((sum, s) => sum + s.value, 0);

const CX = 100;
const CY = 100;
const R = 70;
const CIRC = 2 * Math.PI * R;
const GAP = 3;
const BASE_W = 22;
const ACTIVE_W = 30;

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

function sharePct(value: number): string {
  return `${((value / TOTAL) * 100).toFixed(1)}%`;
}

function legendValue(value: number, mode: Mode): string {
  return mode === "amount" ? currency.format(value) : sharePct(value);
}

function Trend({ change }: { change: number }) {
  const up = change >= 0;
  return (
    <span
      className={`inline-flex items-center gap-0.5 text-[11px] font-semibold tabular-nums ${
        up ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"
      }`}
    >
      <svg viewBox="0 0 12 12" className="h-2.5 w-2.5" aria-hidden="true">
        <path d={up ? "M6 2.5 10 8 2 8Z" : "M6 9.5 2 4 10 4Z"} fill="currentColor" />
      </svg>
      {`${up ? "+" : ""}${change.toFixed(1)}%`}
    </span>
  );
}

export default function ChartDonut() {
  const prefersReduced = useReducedMotion();
  const reduce = prefersReduced === true;

  const [mode, setMode] = useState<Mode>("amount");
  const [selected, setSelected] = useState<string | null>(null);
  const [hovered, setHovered] = useState<string | null>(null);
  const [roving, setRoving] = useState(0);

  const headingId = useId();
  const groupId = useId();
  const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const activeId = hovered ?? selected;

  const arcs = useMemo<Arc[]>(() => {
    let acc = 0;
    return SEGMENTS.map((seg) => {
      const fraction = seg.value / TOTAL;
      const startAngle = -90 + acc * 360;
      const dash = Math.max(fraction * CIRC - GAP, 0.001);
      acc += fraction;
      return { ...seg, fraction, startAngle, dash };
    });
  }, []);

  const active = arcs.find((a) => a.id === activeId) ?? null;

  const summary =
    `Donut chart of Q2 monthly cloud spend, ${currency.format(TOTAL)} total across ${arcs.length} services: ` +
    arcs.map((a) => `${a.label} ${sharePct(a.value)}`).join(", ") +
    ".";

  const centerLabel = active ? active.label : "Total monthly spend";
  const centerPrimary = active ? legendValue(active.value, mode) : currency.format(TOTAL);
  const centerSecondary = active
    ? mode === "amount"
      ? `${sharePct(active.value)} of spend`
      : currency.format(active.value)
    : `Across ${arcs.length} services`;

  function toggle(id: string) {
    setSelected((prev) => (prev === id ? null : id));
  }

  function moveFocus(next: number) {
    const n = (next + arcs.length) % arcs.length;
    setRoving(n);
    btnRefs.current[n]?.focus();
  }

  function handleKey(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    switch (e.key) {
      case "ArrowDown":
      case "ArrowRight":
        e.preventDefault();
        moveFocus(i + 1);
        break;
      case "ArrowUp":
      case "ArrowLeft":
        e.preventDefault();
        moveFocus(i - 1);
        break;
      case "Home":
        e.preventDefault();
        moveFocus(0);
        break;
      case "End":
        e.preventDefault();
        moveFocus(arcs.length - 1);
        break;
    }
  }

  const ring =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";

  return (
    <section
      aria-labelledby={headingId}
      className="relative w-full overflow-hidden bg-white px-6 py-20 dark:bg-slate-950 sm:py-28"
    >
      <style>{`
        @keyframes donutcd_pop {
          0% { opacity: 0; transform: translateY(4px) scale(0.96); }
          100% { opacity: 1; transform: none; }
        }
        @keyframes donutcd_rise {
          0% { opacity: 0; transform: translateY(10px); }
          100% { opacity: 1; transform: none; }
        }
        .donutcd_pop { animation: donutcd_pop 420ms cubic-bezier(0.22, 1, 0.36, 1); }
        .donutcd_rise { animation: donutcd_rise 640ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .donutcd_pop, .donutcd_rise { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 right-0 h-72 w-72 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -bottom-24 left-0 h-72 w-72 rounded-full bg-violet-200/40 blur-3xl dark:bg-violet-500/10"
      />

      <div className="donutcd_rise relative mx-auto max-w-5xl">
        <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-xl">
            <span className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
              Infrastructure · Q2 2026
            </span>
            <h2
              id={headingId}
              className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl"
            >
              Where the cloud budget goes
            </h2>
            <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-300">
              Monthly spend across six services. Select a slice to break it down, or switch
              between dollars and share of total.
            </p>
          </div>

          <div
            role="group"
            aria-label="Value display mode"
            className="inline-flex shrink-0 rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-900"
          >
            {(["amount", "share"] as const).map((m) => {
              const on = mode === m;
              return (
                <button
                  key={m}
                  type="button"
                  aria-pressed={on}
                  onClick={() => setMode(m)}
                  className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${ring} ${
                    on
                      ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                      : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                  }`}
                >
                  {m === "amount" ? "Amount" : "Share"}
                </button>
              );
            })}
          </div>
        </div>

        <div className="mt-12 grid items-center gap-10 lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-16">
          <div className="relative mx-auto aspect-square w-full max-w-[300px]">
            <svg viewBox="0 0 200 200" role="img" aria-label={summary} className="h-full w-full">
              <circle
                cx={CX}
                cy={CY}
                r={R}
                fill="none"
                strokeWidth={BASE_W}
                className="stroke-slate-200 dark:stroke-slate-800"
              />
              {arcs.map((a, i) => {
                const isActive = activeId === a.id;
                const dim = activeId !== null && !isActive;
                return (
                  <g key={a.id} transform={`rotate(${a.startAngle} ${CX} ${CY})`}>
                    <motion.circle
                      cx={CX}
                      cy={CY}
                      r={R}
                      fill="none"
                      strokeLinecap="round"
                      className={`${a.stroke} cursor-pointer`}
                      initial={reduce ? false : { strokeDasharray: `0 ${CIRC}`, strokeWidth: BASE_W }}
                      animate={{
                        strokeDasharray: `${a.dash} ${CIRC - a.dash}`,
                        strokeWidth: isActive ? ACTIVE_W : BASE_W,
                        opacity: dim ? 0.28 : 1,
                      }}
                      transition={{
                        strokeDasharray: {
                          duration: reduce ? 0 : 1,
                          delay: reduce ? 0 : i * 0.09,
                          ease: [0.22, 1, 0.36, 1],
                        },
                        strokeWidth: { duration: reduce ? 0 : 0.28 },
                        opacity: { duration: reduce ? 0 : 0.28 },
                      }}
                      onPointerEnter={() => setHovered(a.id)}
                      onPointerLeave={() => setHovered(null)}
                      onClick={() => toggle(a.id)}
                    />
                  </g>
                );
              })}
            </svg>

            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-10 text-center"
            >
              <span
                className={`mb-2 h-1.5 w-6 rounded-full transition-colors ${
                  active ? active.dot : "bg-slate-300 dark:bg-slate-700"
                }`}
              />
              <span className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">
                {centerLabel}
              </span>
              <span
                key={`${activeId ?? "total"}-${mode}`}
                className="donutcd_pop mt-1 text-3xl font-semibold tracking-tight tabular-nums text-slate-900 dark:text-white sm:text-4xl"
              >
                {centerPrimary}
              </span>
              <span className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                {centerSecondary}
              </span>
            </div>
          </div>

          <div>
            <ul
              role="list"
              aria-label="Spend by service — select a slice to highlight it"
              className="flex flex-col gap-1.5"
            >
              {arcs.map((a, i) => {
                const on = selected === a.id;
                return (
                  <li key={a.id}>
                    <button
                      ref={(el) => {
                        btnRefs.current[i] = el;
                      }}
                      type="button"
                      aria-pressed={on}
                      tabIndex={i === roving ? 0 : -1}
                      onClick={() => toggle(a.id)}
                      onFocus={() => setRoving(i)}
                      onKeyDown={(e) => handleKey(e, i)}
                      onPointerEnter={() => setHovered(a.id)}
                      onPointerLeave={() => setHovered(null)}
                      className={`flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors ${ring} ${
                        on
                          ? "border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-900"
                          : "border-transparent hover:bg-slate-50 dark:hover:bg-slate-900/60"
                      }`}
                    >
                      <span className={`h-3 w-3 shrink-0 rounded-full ${a.dot}`} />
                      <span className="min-w-0 flex-1">
                        <span className="block truncate text-sm font-medium text-slate-800 dark:text-slate-100">
                          {a.label}
                        </span>
                        <span className="mt-0.5 block">
                          <Trend change={a.change} />
                          <span className="ml-2 text-[11px] text-slate-400 dark:text-slate-500">
                            vs Q1
                          </span>
                        </span>
                      </span>
                      <span className="shrink-0 text-right">
                        <span className="block text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
                          {legendValue(a.value, mode)}
                        </span>
                        <span className="mt-0.5 block text-[11px] text-slate-400 dark:text-slate-500 tabular-nums">
                          {mode === "amount" ? sharePct(a.value) : currency.format(a.value)}
                        </span>
                      </span>
                    </button>
                  </li>
                );
              })}
            </ul>

            <div className="mt-4 flex items-center justify-between border-t border-slate-200 px-4 pt-4 dark:border-slate-800">
              <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
                Total
              </span>
              <span className="text-base font-semibold tabular-nums text-slate-900 dark:text-white">
                {currency.format(TOTAL)}
                <span className="ml-1 text-sm font-normal text-slate-400 dark:text-slate-500">
                  /mo
                </span>
              </span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →