Skip to content
All Taste components

Animated charts

Sweeping donut

A share-of-total ring that sweeps round in one continuous stroke, segment by segment, beside its legend.

4 segments · continuous sweep · centre total · legend bars · stacks on narrow widths

Orders by channelLast 30 days
1,284orders
  • Website539 · 42%
  • Mobile app360 · 28%
  • Marketplace231 · 18%
  • In store154 · 12%
/* Add once to globals.css. Each arc grows from nothing to its share; the
   delays line up so the ring sweeps round in one stroke:
@keyframes sweep { from { stroke-dasharray: 0 1; } }
*/

const channels = [
  { label: "Website", orders: 539, tone: "var(--tst-primary)" },
  { label: "Mobile app", orders: 360, tone: "color-mix(in srgb, var(--tst-primary) 62%, var(--tst-bg))" },
  { label: "Marketplace", orders: 231, tone: "color-mix(in srgb, var(--tst-primary) 38%, var(--tst-bg))" },
  { label: "In store", orders: 154, tone: "color-mix(in srgb, var(--tst-primary) 20%, var(--tst-bg))" },
];
const SWEEP = 1.2; // seconds for the full ring
const GAP = 0.006; // hairline between segments

export function SweepingDonut() {
  const total = channels.reduce((s, c) => s + c.orders, 0);
  const top = Math.max(...channels.map((c) => c.orders));
  let start = 0;
  const arcs = channels.map((c) => {
    const share = c.orders / total;
    const arc = { ...c, share, start };
    start += share;
    return arc;
  });

  return (
    <section aria-label="Orders by channel" className="mx-auto grid max-w-xl gap-5 rounded-lg border border-border bg-bg p-[18px] font-sans">
      <div>
        <p className="text-xs font-medium text-fg-subtle">Orders by channel</p>
        <p className="text-[13.5px] text-fg-muted">Last 30 days</p>
      </div>
      <div className="grid items-center gap-6 sm:grid-cols-[180px_1fr] sm:gap-8">
        <div className="relative mx-auto w-[180px]">
          <svg viewBox="0 0 120 120" className="block h-auto w-full -rotate-90" aria-hidden>
            <circle cx="60" cy="60" r="48" fill="none" className="stroke-surface-2" strokeWidth="14" />
            {arcs.map((a) => (
              <circle
                key={a.label}
                cx="60" cy="60" r="48" pathLength={1} fill="none" strokeWidth="14" stroke={a.tone}
                className="animate-[sweep_linear_both] motion-reduce:animate-none"
                style={{
                  strokeDasharray: a.share - GAP + " 1",
                  strokeDashoffset: -a.start,
                  animationDuration: a.share * SWEEP + "s",
                  animationDelay: 0.15 + a.start * SWEEP + "s",
                }}
              />
            ))}
          </svg>
          <div className="absolute inset-0 grid place-content-center text-center animate-pop [animation-delay:1250ms] motion-reduce:animate-none">
            <span className="text-[28px] font-medium tabular-nums tracking-tight text-fg">{total.toLocaleString("en-IN")}</span>
            <span className="text-xs text-fg-subtle">orders</span>
          </div>
        </div>
        <ul className="grid gap-3">
          {arcs.map((a, i) => (
            <li key={a.label} className="grid grid-cols-[10px_1fr_auto] items-center gap-x-2.5 gap-y-1.5 text-[13px] animate-rise motion-reduce:animate-none" style={{ animationDelay: 300 + i * 70 + "ms" }}>
              <i className="size-2.5 rounded-[3px]" style={{ background: a.tone }} />
              <span className="text-fg">{a.label}</span>
              <span className="tabular-nums text-fg-muted">{a.orders} · {Math.round(a.share * 100)}%</span>
              <span className="col-span-2 col-start-2 h-1 overflow-hidden rounded-full bg-surface-2">
                <span className="block h-full origin-left rounded-full animate-grow-x motion-reduce:animate-none" style={{ width: (a.orders / top) * 100 + "%", background: a.tone, animationDelay: 450 + i * 70 + "ms" }} />
              </span>
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.