Chart Radial
Original · freeradial progress bars chart
byWeb InnoventixReact + Tailwind
chartradialcharts
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.jsonchart-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 quoteSimilar components
Browse all →
Chart Bar
Originalhand-built SVG/div bar chart with axes

Chart Bar Grouped
Originalgrouped bar chart with legend

Chart Line
OriginalSVG line chart with points and grid

Chart Area
OriginalSVG area chart with gradient fill

Chart Donut
OriginalSVG donut chart with centre label

Chart Pie
OriginalSVG pie chart with legend

Chart Sparkline
Originalinline sparklines in stat rows

Chart Gauge
Originalsemicircle gauge chart

Chart Stacked Bar
Originalstacked bar chart

Chart Progress
Originalhorizontal progress/metric chart

Chart Heatmap
Originalcontribution-style heatmap grid

Chart Bubble
OriginalSVG bubble/scatter chart

