Chart Progress
Original · freehorizontal progress/metric chart
byWeb InnoventixReact + Tailwind
chartprogresscharts
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-progress.jsonchart-progress.tsx
"use client";
import { useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type MetricKey = "onboarding" | "adoption" | "retention" | "sla" | "wau";
interface Metric {
key: MetricKey;
label: string;
hint: string;
gradient: string;
dot: string;
}
interface Period {
id: string;
label: string;
short: string;
values: Record<MetricKey, number>;
}
const METRICS: Metric[] = [
{
key: "onboarding",
label: "Onboarding completed",
hint: "New workspaces that finish the five-step setup within their first 24 hours.",
gradient: "from-sky-400 to-sky-600",
dot: "bg-sky-500",
},
{
key: "adoption",
label: "Core feature adoption",
hint: "Accounts using automations, shared dashboards and exports in a typical week.",
gradient: "from-violet-400 to-violet-600",
dot: "bg-violet-500",
},
{
key: "retention",
label: "30-day retention",
hint: "Signups still active four weeks after their very first working session.",
gradient: "from-emerald-400 to-emerald-600",
dot: "bg-emerald-500",
},
{
key: "sla",
label: "Support SLA met",
hint: "Tickets answered inside the first-response window we promise on every plan.",
gradient: "from-amber-400 to-amber-600",
dot: "bg-amber-500",
},
{
key: "wau",
label: "Weekly active ratio",
hint: "Share of purchased seats that sign in on any given business week.",
gradient: "from-indigo-400 to-indigo-600",
dot: "bg-indigo-500",
},
];
const PERIODS: Period[] = [
{ id: "q1", label: "Q1 2026", short: "Q1", values: { onboarding: 70, adoption: 55, retention: 69, sla: 85, wau: 49 } },
{ id: "q2", label: "Q2 2026", short: "Q2", values: { onboarding: 78, adoption: 61, retention: 71, sla: 88, wau: 54 } },
{ id: "q3", label: "Q3 2026", short: "Q3", values: { onboarding: 82, adoption: 67, retention: 74, sla: 91, wau: 58 } },
];
type SortMode = "value" | "name";
export default function ChartProgress() {
const prefersReduced = useReducedMotion();
const reduce = prefersReduced ?? false;
const uid = useId();
const [periodIndex, setPeriodIndex] = useState(PERIODS.length - 1);
const [sortMode, setSortMode] = useState<SortMode>("value");
const [showChange, setShowChange] = useState(true);
const [selected, setSelected] = useState<MetricKey | null>("adoption");
const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
const current = PERIODS[periodIndex];
const prev = periodIndex > 0 ? PERIODS[periodIndex - 1] : null;
const compare = showChange && prev !== null;
const rows = useMemo(() => {
const base = METRICS.map((m) => {
const value = current.values[m.key];
const prevValue = prev ? prev.values[m.key] : null;
const delta = prevValue === null ? null : value - prevValue;
return { ...m, value, prevValue, delta };
});
return [...base].sort((a, b) =>
sortMode === "value" ? b.value - a.value : a.label.localeCompare(b.label),
);
}, [current, prev, sortMode]);
const average = Math.round(
METRICS.reduce((sum, m) => sum + current.values[m.key], 0) / METRICS.length,
);
function moveRadio(next: number) {
const count = PERIODS.length;
const idx = ((next % count) + count) % count;
setPeriodIndex(idx);
radioRefs.current[idx]?.focus();
}
function onRadioKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
moveRadio(index + 1);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
moveRadio(index - 1);
break;
case "Home":
e.preventDefault();
moveRadio(0);
break;
case "End":
e.preventDefault();
moveRadio(PERIODS.length - 1);
break;
default:
break;
}
}
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 md:py-28 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes cp-shimmer {
0% { transform: translateX(-160%); }
100% { transform: translateX(360%); }
}
@keyframes cp-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.cp-shimmer { animation: cp-shimmer 1.7s ease-in-out infinite; }
.cp-detail { animation: cp-rise 0.28s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.cp-shimmer, .cp-detail { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-3xl">
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30">
{/* Header */}
<div className="flex flex-col gap-5 border-b border-slate-200 p-6 sm:flex-row sm:items-start sm:justify-between sm:p-8 dark:border-slate-800">
<div>
<span className="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
Product health
</span>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
North-Star targets
</h2>
<p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
Progress against each goal, reported at the close of {current.label}.
</p>
</div>
<div className="flex shrink-0 items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/50">
<div className="text-right">
<div className="text-3xl font-semibold tabular-nums leading-none text-slate-900 dark:text-white">
{average}
<span className="text-lg text-slate-400">%</span>
</div>
<div className="mt-1 text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Blended score
</div>
</div>
</div>
</div>
{/* Controls */}
<div className="flex flex-col gap-4 border-b border-slate-200 px-6 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-8 dark:border-slate-800">
<div role="radiogroup" aria-label="Reporting period" className="inline-flex rounded-xl bg-slate-100 p-1 dark:bg-slate-800">
{PERIODS.map((p, i) => {
const active = i === periodIndex;
return (
<button
key={p.id}
ref={(el) => {
radioRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => setPeriodIndex(i)}
onKeyDown={(e) => onRadioKeyDown(e, i)}
className={`rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-800 ${
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{p.short}
</button>
);
})}
</div>
<div className="flex items-center gap-3">
<button
type="button"
aria-label={
sortMode === "value"
? "Sorted highest first. Activate to sort A to Z."
: "Sorted A to Z. Activate to sort highest first."
}
onClick={() => setSortMode((m) => (m === "value" ? "name" : "value"))}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M4 3v10M4 13l-2-2.5M4 13l2-2.5M10 4h4M10 8h3M10 12h2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{sortMode === "value" ? "Highest first" : "A to Z"}
</button>
<button
type="button"
role="switch"
aria-checked={compare}
aria-label={prev ? `Compare vs ${prev.short}` : "No prior period to compare"}
disabled={!prev}
onClick={() => setShowChange((v) => !v)}
className="group inline-flex items-center gap-2 text-sm font-medium text-slate-600 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 dark:text-slate-300"
>
<span
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors group-focus-visible:ring-2 group-focus-visible:ring-indigo-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-white dark:group-focus-visible:ring-indigo-400 dark:group-focus-visible:ring-offset-slate-900 ${
compare ? "bg-indigo-500 dark:bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
compare ? "translate-x-4" : "translate-x-0.5"
}`}
/>
</span>
<span className="hidden sm:inline">vs {prev ? prev.short : "prior"}</span>
</button>
</div>
</div>
<p aria-live="polite" className="sr-only">
{current.label}: blended score {average} percent.
</p>
{/* Chart */}
<ul className="flex flex-col gap-1.5 p-4 sm:p-6">
{rows.map((row, index) => {
const isSelected = selected === row.key;
const detailId = `${uid}-${row.key}-detail`;
const changeText =
!compare || row.delta === null || !prev
? ""
: `, ${row.delta >= 0 ? "up" : "down"} ${Math.abs(row.delta)} points from ${prev.short}`;
return (
<motion.li key={row.key} layout={!reduce} transition={{ type: "spring", stiffness: 400, damping: 34 }}>
<button
type="button"
aria-expanded={isSelected}
aria-controls={isSelected ? detailId : undefined}
aria-label={`${row.label}: ${row.value} percent${changeText}`}
onClick={() => setSelected((s) => (s === row.key ? null : row.key))}
className={`w-full rounded-2xl border px-4 py-3.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900 ${
isSelected
? "border-slate-300 bg-slate-50 dark:border-slate-600 dark:bg-slate-800/60"
: "border-transparent hover:bg-slate-50 dark:hover:bg-slate-800/40"
}`}
>
<span className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-2.5">
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.dot}`} aria-hidden="true" />
<span className="truncate text-sm font-medium text-slate-700 dark:text-slate-200">
{row.label}
</span>
</span>
<span className="flex shrink-0 items-center gap-2">
{compare && row.delta !== null && (
<span
className={`inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums ${
row.delta >= 0
? "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400"
: "bg-rose-50 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400"
}`}
>
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
{row.delta >= 0 ? (
<path d="M6 9.5V2.5M6 2.5L3 5.5M6 2.5L9 5.5" strokeLinecap="round" strokeLinejoin="round" />
) : (
<path d="M6 2.5v7M6 9.5L3 6.5M6 9.5L9 6.5" strokeLinecap="round" strokeLinejoin="round" />
)}
</svg>
{Math.abs(row.delta)}
</span>
)}
<span className="w-11 text-right text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
{row.value}%
</span>
</span>
</span>
<span className="relative mt-3 block h-3 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-700/60">
<motion.span
className={`absolute inset-y-0 left-0 block overflow-hidden rounded-full bg-gradient-to-r ${row.gradient}`}
initial={reduce ? false : { width: 0 }}
animate={{ width: `${row.value}%` }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 150, damping: 24, delay: 0.04 * index }
}
>
{isSelected && !reduce && (
<span
aria-hidden="true"
className="cp-shimmer pointer-events-none absolute inset-y-0 left-0 block w-1/3 bg-gradient-to-r from-transparent via-white/55 to-transparent"
/>
)}
</motion.span>
{compare && row.prevValue !== null && (
<span
aria-hidden="true"
className="absolute top-1/2 z-10 h-4 w-[3px] -translate-y-1/2 rounded-full bg-slate-500/80 ring-1 ring-white/70 dark:bg-slate-100/80 dark:ring-slate-900/70"
style={{ left: `calc(${row.prevValue}% - 1.5px)` }}
/>
)}
</span>
</button>
{isSelected && (
<div
id={detailId}
className="cp-detail mt-1.5 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/40"
>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">{row.hint}</p>
<dl className="mt-3 grid grid-cols-3 gap-3">
<div>
<dt className="text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
{current.short}
</dt>
<dd className="mt-0.5 text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
{row.value}%
</dd>
</div>
{row.prevValue !== null && prev && (
<div>
<dt className="text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
{prev.short}
</dt>
<dd className="mt-0.5 text-sm font-semibold tabular-nums text-slate-600 dark:text-slate-300">
{row.prevValue}%
</dd>
</div>
)}
{row.delta !== null && (
<div>
<dt className="text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
Change
</dt>
<dd
className={`mt-0.5 text-sm font-semibold tabular-nums ${
row.delta >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400"
}`}
>
{row.delta >= 0 ? "+" : "−"}
{Math.abs(row.delta)} pts
</dd>
</div>
)}
</dl>
</div>
)}
</motion.li>
);
})}
</ul>
{/* Footer */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 text-xs text-slate-500 sm:px-8 dark:border-slate-800 dark:text-slate-400">
<span>Select a metric to expand its definition and movement.</span>
{compare && (
<span className="inline-flex items-center gap-1.5">
<span className="h-3.5 w-[3px] rounded-full bg-slate-500/80 dark:bg-slate-100/80" aria-hidden="true" />
{prev?.short} benchmark
</span>
)}
</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 Radial
Originalradial progress bars chart

Chart Sparkline
Originalinline sparklines in stat rows

Chart Gauge
Originalsemicircle gauge chart

Chart Stacked Bar
Originalstacked bar chart

Chart Heatmap
Originalcontribution-style heatmap grid

Chart Bubble
OriginalSVG bubble/scatter chart

