Web InnoventixFreeCode

Chart Radial

Original · free

radial progress bars chart

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

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

type Metric = {
  id: string;
  label: string;
  hint: string;
  radius: number;
  strokeClass: string;
  dotClass: string;
  barClass: string;
  glow: string;
  values: [number, number];
};

type Period = { id: string; label: string; short: string };

const PERIODS: Period[] = [
  { id: "q3", label: "This quarter", short: "Q3" },
  { id: "q2", label: "Last quarter", short: "Q2" },
];

const METRICS: Metric[] = [
  {
    id: "revenue",
    label: "Revenue attainment",
    hint: "against the $2.4M quarterly plan",
    radius: 132,
    strokeClass: "stroke-indigo-500 dark:stroke-indigo-400",
    dotClass: "bg-indigo-500 dark:bg-indigo-400",
    barClass: "bg-indigo-500 dark:bg-indigo-400",
    glow: "rgba(99,102,241,0.55)",
    values: [78, 64],
  },
  {
    id: "activation",
    label: "Activation rate",
    hint: "new signups reaching first value",
    radius: 108,
    strokeClass: "stroke-emerald-500 dark:stroke-emerald-400",
    dotClass: "bg-emerald-500 dark:bg-emerald-400",
    barClass: "bg-emerald-500 dark:bg-emerald-400",
    glow: "rgba(16,185,129,0.55)",
    values: [92, 71],
  },
  {
    id: "retention",
    label: "90-day retention",
    hint: "cohort still active after 90 days",
    radius: 84,
    strokeClass: "stroke-violet-500 dark:stroke-violet-400",
    dotClass: "bg-violet-500 dark:bg-violet-400",
    barClass: "bg-violet-500 dark:bg-violet-400",
    glow: "rgba(139,92,246,0.55)",
    values: [66, 69],
  },
  {
    id: "csat",
    label: "Support CSAT",
    hint: "tickets rated good or great",
    radius: 60,
    strokeClass: "stroke-amber-500 dark:stroke-amber-400",
    dotClass: "bg-amber-500 dark:bg-amber-400",
    barClass: "bg-amber-500 dark:bg-amber-400",
    glow: "rgba(245,158,11,0.55)",
    values: [88, 80],
  },
  {
    id: "uptime",
    label: "Platform uptime",
    hint: "measured against the 99.9% SLA",
    radius: 36,
    strokeClass: "stroke-sky-500 dark:stroke-sky-400",
    dotClass: "bg-sky-500 dark:bg-sky-400",
    barClass: "bg-sky-500 dark:bg-sky-400",
    glow: "rgba(14,165,233,0.55)",
    values: [99, 97],
  },
];

const CENTER = 160;
const STROKE = 15;
const TARGET = 80;

function circ(r: number) {
  return 2 * Math.PI * r;
}

function useCountUp(target: number, animated: boolean) {
  const [value, setValue] = useState(target);
  const rafRef = useRef<number | null>(null);
  const fromRef = useRef(target);

  useEffect(() => {
    const from = fromRef.current;
    if (!animated || from === target) {
      fromRef.current = target;
      return;
    }
    const start = performance.now();
    const duration = 650;
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / duration);
      const eased = 1 - Math.pow(1 - t, 3);
      const next = from + (target - from) * eased;
      setValue(next);
      fromRef.current = next;
      if (t < 1) rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
  }, [target, animated]);

  return animated ? Math.round(value) : target;
}

function DeltaBadge({
  delta,
  otherShort,
  metricLabel,
}: {
  delta: number;
  otherShort: string;
  metricLabel: string;
}) {
  const up = delta > 0;
  const flat = delta === 0;
  const tone = flat
    ? "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300"
    : up
      ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
      : "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300";
  const readout = flat
    ? `${metricLabel}: no change vs ${otherShort}`
    : `${metricLabel}: ${up ? "up" : "down"} ${Math.abs(delta)} points vs ${otherShort}`;
  return (
    <span
      role="img"
      aria-label={readout}
      className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold ${tone}`}
    >
      <svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true" className="shrink-0">
        {flat ? (
          <path d="M2 6h8" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
        ) : up ? (
          <path
            d="M6 2.2 9.5 7.2H2.5z"
            fill="currentColor"
          />
        ) : (
          <path d="M6 9.8 2.5 4.8h7z" fill="currentColor" />
        )}
      </svg>
      {flat ? "no change" : `${up ? "+" : ""}${delta} pts`}
      <span className="font-normal opacity-70">vs {otherShort}</span>
    </span>
  );
}

export default function ChartRadial() {
  const prefersReduced = useReducedMotion();
  const reduced = !!prefersReduced;

  const [period, setPeriod] = useState(0);
  const [selected, setSelected] = useState(0);

  const legendRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const periodRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const active = METRICS[selected];
  const otherPeriod = period === 0 ? 1 : 0;
  const activeValue = active.values[period];
  const delta = active.values[period] - active.values[otherPeriod];
  const shownValue = useCountUp(activeValue, !reduced);

  const periodValues = METRICS.map((m) => m.values[period]);
  const average = Math.round(periodValues.reduce((a, b) => a + b, 0) / METRICS.length);
  const onTrack = periodValues.filter((v) => v >= TARGET).length;

  function moveLegend(dir: number) {
    const n = METRICS.length;
    const next = (selected + dir + n) % n;
    setSelected(next);
    legendRefs.current[next]?.focus();
  }

  function onLegendKey(e: KeyboardEvent<HTMLDivElement>) {
    switch (e.key) {
      case "ArrowDown":
      case "ArrowRight":
        e.preventDefault();
        moveLegend(1);
        break;
      case "ArrowUp":
      case "ArrowLeft":
        e.preventDefault();
        moveLegend(-1);
        break;
      case "Home":
        e.preventDefault();
        setSelected(0);
        legendRefs.current[0]?.focus();
        break;
      case "End":
        e.preventDefault();
        setSelected(METRICS.length - 1);
        legendRefs.current[METRICS.length - 1]?.focus();
        break;
      default:
        break;
    }
  }

  function movePeriod(dir: number) {
    const n = PERIODS.length;
    const next = (period + dir + n) % n;
    setPeriod(next);
    periodRefs.current[next]?.focus();
  }

  function onPeriodKey(e: KeyboardEvent<HTMLDivElement>) {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        movePeriod(1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        movePeriod(-1);
        break;
      case "Home":
        e.preventDefault();
        setPeriod(0);
        periodRefs.current[0]?.focus();
        break;
      case "End":
        e.preventDefault();
        setPeriod(PERIODS.length - 1);
        periodRefs.current[PERIODS.length - 1]?.focus();
        break;
      default:
        break;
    }
  }

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

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes cr-radial-pop {
          0% { transform: scale(0.9); opacity: 0.35; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes cr-radial-pulse {
          0%, 100% { opacity: 0.35; transform: scale(0.96); }
          50% { opacity: 0.6; transform: scale(1.05); }
        }
        .cr-radial-value { animation: cr-radial-pop 0.45s cubic-bezier(0.22, 1, 0.36, 1); }
        .cr-radial-glow { animation: cr-radial-pulse 5.5s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cr-radial-value, .cr-radial-glow { animation: none !important; }
        }
      `}</style>

      <div className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-gradient-to-b from-indigo-100/50 to-transparent dark:from-indigo-500/5" />

      <div className="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">
            <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
              Quarterly scorecard
            </p>
            <h2 className="mt-3 text-balance text-3xl font-bold tracking-tight sm:text-4xl">
              Five goals, one radial read-out
            </h2>
            <p className="mt-3 text-pretty text-sm text-slate-600 sm:text-base dark:text-slate-400">
              Each ring is a team goal drawn to its completion. Pick a metric to inspect it, or
              flip between quarters to see what moved.
            </p>
          </div>

          <div
            role="radiogroup"
            aria-label="Select reporting period"
            onKeyDown={onPeriodKey}
            className="inline-flex shrink-0 rounded-full border border-slate-200 bg-white p-1 shadow-sm dark:border-slate-800 dark:bg-slate-900"
          >
            {PERIODS.map((p, i) => {
              const isActive = period === i;
              return (
                <button
                  key={p.id}
                  ref={(el) => {
                    periodRefs.current[i] = el;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={isActive}
                  tabIndex={isActive ? 0 : -1}
                  onClick={() => setPeriod(i)}
                  className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${focusRing} ${
                    isActive
                      ? "bg-slate-900 text-white dark:bg-white dark:text-slate-900"
                      : "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
                  }`}
                >
                  {p.label}
                </button>
              );
            })}
          </div>
        </div>

        <div className="mt-12 grid items-center gap-10 lg:grid-cols-2 lg:gap-8">
          {/* Chart */}
          <div className="flex justify-center">
            <div className="relative w-full max-w-[360px]">
              <div
                aria-hidden="true"
                className="cr-radial-glow pointer-events-none absolute left-1/2 top-1/2 h-40 w-40 -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl"
                style={{ background: `radial-gradient(circle, ${active.glow}, transparent 70%)` }}
              />

              <svg
                viewBox="0 0 320 320"
                role="img"
                aria-label={`Radial chart of ${METRICS.length} team goals for ${PERIODS[period].label}. ${active.label} is at ${activeValue} percent.`}
                className="relative w-full"
              >
                {METRICS.map((m) => (
                  <circle
                    key={`track-${m.id}`}
                    cx={CENTER}
                    cy={CENTER}
                    r={m.radius}
                    fill="none"
                    strokeWidth={STROKE}
                    className="stroke-slate-200/80 dark:stroke-slate-800"
                  />
                ))}

                {METRICS.map((m, i) => {
                  const c = circ(m.radius);
                  const value = m.values[period];
                  const offset = c * (1 - value / 100);
                  const isActive = selected === i;
                  const op = isActive ? 1 : 0.28;
                  return (
                    <g key={`arc-${m.id}`} className="cursor-pointer">
                      <motion.circle
                        cx={CENTER}
                        cy={CENTER}
                        r={m.radius}
                        fill="none"
                        strokeWidth={STROKE}
                        strokeLinecap="round"
                        transform={`rotate(-90 ${CENTER} ${CENTER})`}
                        className={m.strokeClass}
                        strokeDasharray={c}
                        style={{ pointerEvents: "none" }}
                        initial={{
                          strokeDashoffset: reduced ? offset : c,
                          opacity: reduced ? op : 0,
                        }}
                        animate={{ strokeDashoffset: offset, opacity: op }}
                        transition={{
                          strokeDashoffset: {
                            duration: reduced ? 0 : 1.1,
                            ease: [0.22, 1, 0.36, 1],
                            delay: reduced ? 0 : i * 0.08,
                          },
                          opacity: { duration: reduced ? 0 : 0.22, ease: "easeOut" },
                        }}
                      />
                      <circle
                        cx={CENTER}
                        cy={CENTER}
                        r={m.radius}
                        fill="none"
                        stroke="transparent"
                        strokeWidth={STROKE + 8}
                        transform={`rotate(-90 ${CENTER} ${CENTER})`}
                        style={{ pointerEvents: "stroke" }}
                        onClick={() => setSelected(i)}
                        onMouseEnter={() => setSelected(i)}
                      >
                        <title>{`${m.label}: ${value}%`}</title>
                      </circle>
                    </g>
                  );
                })}
              </svg>

              {/* Center read-out */}
              <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
                <span className="text-[0.7rem] font-semibold uppercase tracking-widest text-slate-500 dark:text-slate-400">
                  {active.label}
                </span>
                <span
                  key={`${active.id}-${period}`}
                  className={`mt-1 text-5xl font-bold tabular-nums ${reduced ? "" : "cr-radial-value"}`}
                >
                  {shownValue}
                  <span className="align-top text-2xl font-semibold text-slate-400 dark:text-slate-500">
                    %
                  </span>
                </span>
                <span className="mt-1.5 max-w-[9rem] text-[0.7rem] leading-tight text-slate-500 dark:text-slate-400">
                  {active.hint}
                </span>
              </div>
            </div>
          </div>

          {/* Legend + summary */}
          <div>
            <div
              role="radiogroup"
              aria-label="Select a metric to inspect"
              onKeyDown={onLegendKey}
              className="flex flex-col gap-2"
            >
              {METRICS.map((m, i) => {
                const isActive = selected === i;
                const value = m.values[period];
                return (
                  <button
                    key={m.id}
                    ref={(el) => {
                      legendRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={isActive}
                    tabIndex={isActive ? 0 : -1}
                    onClick={() => setSelected(i)}
                    className={`group flex items-center gap-4 rounded-2xl border px-4 py-3 text-left transition-all ${focusRing} ${
                      isActive
                        ? "border-slate-300 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900"
                        : "border-transparent hover:border-slate-200 hover:bg-white/60 dark:hover:border-slate-800 dark:hover:bg-slate-900/50"
                    }`}
                  >
                    <span
                      aria-hidden="true"
                      className={`h-2.5 w-2.5 shrink-0 rounded-full transition-transform ${m.dotClass} ${
                        isActive ? "scale-125" : "opacity-60 group-hover:opacity-100"
                      }`}
                    />
                    <span className="min-w-0 flex-1">
                      <span className="flex items-baseline justify-between gap-3">
                        <span
                          className={`truncate text-sm font-semibold ${
                            isActive
                              ? "text-slate-900 dark:text-white"
                              : "text-slate-600 dark:text-slate-300"
                          }`}
                        >
                          {m.label}
                        </span>
                        <span className="shrink-0 text-sm font-bold tabular-nums text-slate-900 dark:text-white">
                          {value}%
                        </span>
                      </span>
                      <span className="mt-2 flex h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
                        <span
                          className={`h-full rounded-full ${m.barClass}`}
                          style={{ width: `${value}%` }}
                        />
                      </span>
                    </span>
                  </button>
                );
              })}
            </div>

            <div className="mt-5 flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 dark:border-slate-800 dark:bg-slate-900">
              <div className="flex flex-col">
                <span className="text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
                  {average}%
                </span>
                <span className="text-xs text-slate-500 dark:text-slate-400">average across goals</span>
              </div>
              <span className="hidden h-8 w-px bg-slate-200 sm:block dark:bg-slate-800" />
              <div className="flex flex-col">
                <span className="text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
                  {onTrack}/{METRICS.length}
                </span>
                <span className="text-xs text-slate-500 dark:text-slate-400">
                  at or above the {TARGET}% target
                </span>
              </div>
              <div className="ml-auto flex flex-col items-start gap-1 sm:items-end">
                <DeltaBadge
                  delta={delta}
                  otherShort={PERIODS[otherPeriod].short}
                  metricLabel={active.label}
                />
                <span className="text-xs text-slate-500 dark:text-slate-400">{active.label}</span>
              </div>
            </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 →