Web InnoventixFreeCode

Chart Pie

Original · free

SVG pie chart with legend

byWeb InnoventixReact + Tailwind
chartpiecharts
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-pie.json
chart-pie.tsx
"use client";

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

type Channel = {
  id: string;
  label: string;
  hint: string;
  now: number;
  prev: number;
  fill: string;
  swatch: string;
  ring: string;
};

const CHANNELS: Channel[] = [
  {
    id: "organic",
    label: "Organic search",
    hint: "Unpaid Google & Bing results",
    now: 48210,
    prev: 44120,
    fill: "text-emerald-500 dark:text-emerald-400",
    swatch: "bg-emerald-500 dark:bg-emerald-400",
    ring: "focus-visible:ring-emerald-500",
  },
  {
    id: "direct",
    label: "Direct",
    hint: "Typed URL or bookmark",
    now: 22940,
    prev: 21050,
    fill: "text-indigo-500 dark:text-indigo-400",
    swatch: "bg-indigo-500 dark:bg-indigo-400",
    ring: "focus-visible:ring-indigo-500",
  },
  {
    id: "referral",
    label: "Referral",
    hint: "Links from other sites",
    now: 12680,
    prev: 14300,
    fill: "text-sky-500 dark:text-sky-400",
    swatch: "bg-sky-500 dark:bg-sky-400",
    ring: "focus-visible:ring-sky-500",
  },
  {
    id: "social",
    label: "Social",
    hint: "LinkedIn, X and Reddit",
    now: 9430,
    prev: 7980,
    fill: "text-violet-500 dark:text-violet-400",
    swatch: "bg-violet-500 dark:bg-violet-400",
    ring: "focus-visible:ring-violet-500",
  },
  {
    id: "email",
    label: "Email",
    hint: "Newsletter & lifecycle",
    now: 6120,
    prev: 5640,
    fill: "text-amber-500 dark:text-amber-400",
    swatch: "bg-amber-500 dark:bg-amber-400",
    ring: "focus-visible:ring-amber-500",
  },
  {
    id: "paid",
    label: "Paid ads",
    hint: "Google & Meta campaigns",
    now: 4560,
    prev: 6210,
    fill: "text-rose-500 dark:text-rose-400",
    swatch: "bg-rose-500 dark:bg-rose-400",
    ring: "focus-visible:ring-rose-500",
  },
];

const PERIODS = [
  { id: "this", label: "This month" },
  { id: "last", label: "Last month" },
] as const;
type PeriodId = (typeof PERIODS)[number]["id"];

const CX = 120;
const CY = 120;
const R_OUT = 100;
const R_IN = 62;
const GAP = 2.4;

const nf = new Intl.NumberFormat("en-US");

function amount(c: Channel, period: PeriodId): number {
  return period === "this" ? c.now : c.prev;
}

function pointOnCircle(r: number, deg: number): { x: number; y: number } {
  const rad = ((deg - 90) * Math.PI) / 180;
  return { x: CX + r * Math.cos(rad), y: CY + r * Math.sin(rad) };
}

function arcPath(startDeg: number, endDeg: number): string {
  const large = endDeg - startDeg > 180 ? 1 : 0;
  const o1 = pointOnCircle(R_OUT, startDeg);
  const o2 = pointOnCircle(R_OUT, endDeg);
  const i2 = pointOnCircle(R_IN, endDeg);
  const i1 = pointOnCircle(R_IN, startDeg);
  return [
    `M ${o1.x.toFixed(3)} ${o1.y.toFixed(3)}`,
    `A ${R_OUT} ${R_OUT} 0 ${large} 1 ${o2.x.toFixed(3)} ${o2.y.toFixed(3)}`,
    `L ${i2.x.toFixed(3)} ${i2.y.toFixed(3)}`,
    `A ${R_IN} ${R_IN} 0 ${large} 0 ${i1.x.toFixed(3)} ${i1.y.toFixed(3)}`,
    "Z",
  ].join(" ");
}

function fullRingPath(): string {
  return [
    `M ${CX - R_OUT} ${CY}`,
    `a ${R_OUT} ${R_OUT} 0 1 0 ${R_OUT * 2} 0`,
    `a ${R_OUT} ${R_OUT} 0 1 0 ${-R_OUT * 2} 0`,
    "Z",
    `M ${CX - R_IN} ${CY}`,
    `a ${R_IN} ${R_IN} 0 1 1 ${R_IN * 2} 0`,
    `a ${R_IN} ${R_IN} 0 1 1 ${-R_IN * 2} 0`,
    "Z",
  ].join(" ");
}

type Segment = {
  channel: Channel;
  value: number;
  frac: number;
  d: string;
  dx: number;
  dy: number;
};

export default function ChartPie() {
  const reduce = useReducedMotion();
  const uid = useId();
  const [period, setPeriod] = useState<PeriodId>("this");
  const [hidden, setHidden] = useState<Set<string>>(() => new Set());
  const [active, setActive] = useState<string | null>(null);
  const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const visibleCount = CHANNELS.length - hidden.size;

  const { segments, total } = useMemo(() => {
    const vis = CHANNELS.filter((c) => !hidden.has(c.id));
    const sum = vis.reduce((s, c) => s + amount(c, period), 0);
    if (vis.length === 0 || sum === 0) return { segments: [] as Segment[], total: 0 };

    let cursor = 0;
    const list: Segment[] = vis.map((c) => {
      const value = amount(c, period);
      const frac = value / sum;
      const sweep = frac * 360;
      const start = cursor;
      const end = cursor + sweep;
      cursor = end;
      const g = sweep > GAP * 3 ? GAP / 2 : 0;
      const d = vis.length === 1 ? fullRingPath() : arcPath(start + g, end - g);
      const mid = (start + end) / 2;
      const rad = ((mid - 90) * Math.PI) / 180;
      return { channel: c, value, frac, d, dx: Math.cos(rad), dy: Math.sin(rad) };
    });
    return { segments: list, total: sum };
  }, [hidden, period]);

  const shareById = useMemo(() => {
    const m = new Map<string, number>();
    segments.forEach((s) => m.set(s.channel.id, s.frac));
    return m;
  }, [segments]);

  const activeSeg = active ? segments.find((s) => s.channel.id === active) ?? null : null;
  const periodLabel = PERIODS.find((p) => p.id === period)?.label ?? "";

  const chartSummary = `Donut chart of website sessions by channel for ${periodLabel.toLowerCase()}. Total ${nf.format(
    total,
  )} sessions across ${visibleCount} of ${CHANNELS.length} channels.`;

  function toggleHidden(id: string) {
    setHidden((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
    setActive((a) => (a === id ? null : a));
  }

  function onRadioKey(e: KeyboardEvent<HTMLButtonElement>, idx: number) {
    const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"];
    if (!keys.includes(e.key)) return;
    e.preventDefault();
    const dir = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : -1;
    const next = (idx + dir + PERIODS.length) % PERIODS.length;
    setPeriod(PERIODS[next].id);
    radioRefs.current[next]?.focus();
  }

  const activeDelta = activeSeg
    ? activeSeg.channel.prev > 0
      ? ((activeSeg.channel.now - activeSeg.channel.prev) / activeSeg.channel.prev) * 100
      : 0
    : 0;
  const activeUp = activeDelta >= 0;

  return (
    <section className="relative w-full overflow-hidden bg-white px-4 py-16 text-zinc-900 sm:px-6 sm:py-24 dark:bg-zinc-950 dark:text-zinc-50">
      <style>{`
        @keyframes chartpie-rise {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes chartpie-spin { to { transform: rotate(360deg); } }
        @media (prefers-reduced-motion: reduce) {
          .chartpie-anim, .chartpie-spin { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="chartpie-anim [animation:chartpie-rise_0.6s_ease-out_both] max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1 text-xs font-medium tracking-wide text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" />
            Northwind Analytics
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight sm:text-3xl">
            Where your traffic comes from
          </h2>
          <p className="mt-2 text-sm leading-relaxed text-zinc-600 sm:text-base dark:text-zinc-400">
            Sessions on northwind.app broken down by acquisition channel. Switch the period,
            hover a slice, or hide a channel to watch the mix recalculate.
          </p>
        </div>

        <div
          className="chartpie-anim mt-8 grid grid-cols-1 items-center gap-8 rounded-3xl border border-zinc-200 bg-zinc-50/60 p-5 sm:p-8 lg:grid-cols-2 lg:gap-12 dark:border-zinc-800 dark:bg-zinc-900/40"
          style={{ animation: "chartpie-rise 0.7s ease-out 0.08s both" }}
        >
          {/* Chart */}
          <div className="flex flex-col items-center">
            <div className="relative mx-auto aspect-square w-full max-w-[320px]">
              <svg
                viewBox="0 0 240 240"
                className="h-full w-full"
                role="img"
                aria-label={chartSummary}
              >
                {/* decorative dashed guide ring */}
                <circle
                  cx={CX}
                  cy={CY}
                  r={112}
                  fill="none"
                  strokeWidth={1}
                  strokeDasharray="2 9"
                  className="chartpie-spin text-zinc-300 [animation:chartpie-spin_48s_linear_infinite] dark:text-zinc-700"
                  stroke="currentColor"
                  style={{ transformBox: "fill-box", transformOrigin: "center" }}
                  aria-hidden="true"
                />
                {/* static track */}
                <circle
                  cx={CX}
                  cy={CY}
                  r={(R_OUT + R_IN) / 2}
                  fill="none"
                  strokeWidth={R_OUT - R_IN}
                  className="text-zinc-100 dark:text-zinc-800/70"
                  stroke="currentColor"
                  aria-hidden="true"
                />

                <motion.g
                  aria-hidden="true"
                  style={{ transformBox: "fill-box", transformOrigin: "center" }}
                  initial={reduce ? false : { rotate: -8, scale: 0.94 }}
                  animate={{ rotate: 0, scale: 1 }}
                  transition={reduce ? { duration: 0 } : { duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
                >
                  {segments.map((seg, i) => {
                    const dimmed = active !== null && active !== seg.channel.id;
                    const isActive = active === seg.channel.id;
                    const off = isActive ? 7 : 0;
                    return (
                      <motion.path
                        key={seg.channel.id}
                        d={seg.d}
                        fill="currentColor"
                        fillRule="evenodd"
                        className={`${seg.channel.fill} cursor-pointer outline-none`}
                        initial={reduce ? false : { opacity: 0 }}
                        animate={{
                          opacity: dimmed ? 0.32 : 1,
                          x: seg.dx * off,
                          y: seg.dy * off,
                        }}
                        transition={
                          reduce
                            ? { duration: 0 }
                            : {
                                opacity: { duration: 0.5, delay: i * 0.06 },
                                x: { type: "spring", stiffness: 260, damping: 22 },
                                y: { type: "spring", stiffness: 260, damping: 22 },
                              }
                        }
                        onMouseEnter={() => setActive(seg.channel.id)}
                        onMouseLeave={() => setActive(null)}
                      />
                    );
                  })}
                </motion.g>
              </svg>

              {/* centre readout */}
              <div className="pointer-events-none absolute inset-0 grid place-items-center px-10 text-center">
                {activeSeg ? (
                  <div>
                    <p className="text-[0.65rem] font-medium uppercase tracking-widest text-zinc-500 dark:text-zinc-400">
                      {activeSeg.channel.label}
                    </p>
                    <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
                      {nf.format(activeSeg.value)}
                    </p>
                    <p className="mt-0.5 text-xs font-medium text-zinc-500 dark:text-zinc-400">
                      {(activeSeg.frac * 100).toFixed(1)}% of total
                    </p>
                    {period === "this" && (
                      <p
                        className={`mt-1.5 inline-flex items-center gap-1 text-xs font-semibold ${
                          activeUp
                            ? "text-emerald-600 dark:text-emerald-400"
                            : "text-rose-600 dark:text-rose-400"
                        }`}
                      >
                        <svg viewBox="0 0 12 12" className="h-3 w-3" aria-hidden="true">
                          <path
                            d={activeUp ? "M6 3 L10 8 L2 8 Z" : "M6 9 L2 4 L10 4 Z"}
                            fill="currentColor"
                          />
                        </svg>
                        {activeUp ? "+" : "−"}
                        {Math.abs(activeDelta).toFixed(1)}% MoM
                      </p>
                    )}
                  </div>
                ) : total > 0 ? (
                  <div>
                    <p className="text-[0.65rem] font-medium uppercase tracking-widest text-zinc-500 dark:text-zinc-400">
                      Total sessions
                    </p>
                    <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight sm:text-4xl">
                      {nf.format(total)}
                    </p>
                    <p className="mt-0.5 text-xs font-medium text-zinc-500 dark:text-zinc-400">
                      {visibleCount} of {CHANNELS.length} channels &middot; {periodLabel}
                    </p>
                  </div>
                ) : (
                  <div>
                    <p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
                      No channels selected
                    </p>
                    <p className="mt-1 text-xs text-zinc-400 dark:text-zinc-500">
                      Turn a channel back on to see the mix.
                    </p>
                  </div>
                )}
              </div>
            </div>

            {/* Period control */}
            <div
              role="radiogroup"
              aria-label="Reporting period"
              className="mt-6 inline-flex rounded-full border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-800 dark:bg-zinc-800/60"
            >
              {PERIODS.map((p, idx) => {
                const selected = p.id === period;
                return (
                  <button
                    key={p.id}
                    ref={(el) => {
                      radioRefs.current[idx] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={selected}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setPeriod(p.id)}
                    onKeyDown={(e) => onRadioKey(e, idx)}
                    className={`rounded-full px-4 py-1.5 text-sm font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-800 ${
                      selected
                        ? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-950 dark:text-zinc-50"
                        : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
                    }`}
                  >
                    {p.label}
                  </button>
                );
              })}
            </div>
          </div>

          {/* Legend */}
          <div>
            <div className="mb-3 flex items-center justify-between">
              <h3 className="text-sm font-semibold text-zinc-700 dark:text-zinc-300" id={`${uid}-legend`}>
                Channels
              </h3>
              <button
                type="button"
                aria-disabled={hidden.size === 0}
                onClick={() => {
                  if (hidden.size > 0) setHidden(new Set());
                }}
                className={`rounded-md px-1.5 py-0.5 text-xs font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 ${
                  hidden.size === 0
                    ? "cursor-default text-zinc-400 dark:text-zinc-600"
                    : "text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
                }`}
              >
                Reset all
              </button>
            </div>

            <ul aria-labelledby={`${uid}-legend`} className="space-y-1">
              {CHANNELS.map((c, i) => {
                const isHidden = hidden.has(c.id);
                const share = shareById.get(c.id);
                return (
                  <li key={c.id}>
                    <button
                      type="button"
                      aria-pressed={!isHidden}
                      onClick={() => toggleHidden(c.id)}
                      onMouseEnter={() => !isHidden && setActive(c.id)}
                      onMouseLeave={() => setActive(null)}
                      onFocus={() => !isHidden && setActive(c.id)}
                      onBlur={() => setActive(null)}
                      className={`chartpie-anim flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-900 ${c.ring} hover:bg-white dark:hover:bg-zinc-800/50 ${
                        isHidden ? "opacity-50" : ""
                      }`}
                      style={{ animation: `chartpie-rise 0.5s ease-out ${0.15 + i * 0.05}s both` }}
                    >
                      <span
                        aria-hidden="true"
                        className={`h-3 w-3 shrink-0 rounded-full ${
                          isHidden
                            ? "border border-zinc-400 dark:border-zinc-500"
                            : c.swatch
                        }`}
                      />
                      <span className="min-w-0 flex-1">
                        <span
                          className={`block truncate text-sm font-medium ${
                            isHidden
                              ? "text-zinc-500 line-through dark:text-zinc-500"
                              : "text-zinc-900 dark:text-zinc-100"
                          }`}
                        >
                          {c.label}
                        </span>
                        <span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">
                          {c.hint}
                        </span>
                      </span>
                      <span className="shrink-0 text-right">
                        <span className="block text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
                          {nf.format(amount(c, period))}
                        </span>
                        <span className="block text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
                          {isHidden || share === undefined
                            ? "hidden"
                            : `${(share * 100).toFixed(1)}%`}
                        </span>
                      </span>
                    </button>
                  </li>
                );
              })}
            </ul>

            <p className="mt-4 border-t border-zinc-200 pt-3 text-xs text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
              Source: Northwind Analytics &middot; Sessions, not unique visitors &middot; MoM = month over month.
            </p>
          </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 →