JSX
Copy
// Goal and progress widgets: SVG progress rings, target bars with an expected-pace marker,
// and an annual goal on a native <progress> with milestones. Every bar is a labelled progressbar
// with a spoken value; status pills pair an icon with words, so colour is never the only signal.
// Usage: <ProgressGoals rings={[…]} targets={{…}} goal={{…}} /> — all props have demo data.
import { useId } from 'react';
const STATUS = {
onTrack: { cls: 'bg-[#dcfce7] text-[#15803d]', icon: <path d="M20 6 9 17l-5-5" /> },
ahead: { cls: 'bg-[#dcfce7] text-[#15803d]', icon: <path d="M12 19V5M5 12l7-7 7 7" /> },
behind: { cls: 'bg-[#fee2e2] text-[#b91c1c]', icon: <path d="M12 5v14M19 12l-7 7-7-7" /> },
warning: { cls: 'bg-[#fef3c7] text-[#b45309]', icon: <><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /></> },
};
const defaultRings = [
{ title: 'Q4 sales target', percent: 72, detail: '$360,000 of $500,000', color: 'stroke-[#2a78d6]', status: 'onTrack', statusText: 'On track' },
{ title: 'Storage used', percent: 86, detail: '43 GB of 50 GB', color: 'stroke-[#d97706]', status: 'warning', statusText: 'Near limit' },
{ title: 'Onboarding done', percent: 91, detail: '1,183 of 1,300 accounts', color: 'stroke-[#2a78d6]', status: 'onTrack', statusText: 'On track' },
];
const defaultTargets = {
title: 'October targets', subtitle: 'Day 16 of 31 · updated 09:40', pace: 52,
items: [
{ label: 'New MRR', value: 9820, max: 15000, format: 'usd', status: 'ahead', statusText: 'Ahead of pace' },
{ label: 'Enterprise demos booked', value: 21, max: 40, status: 'onTrack', statusText: 'On pace' },
{ label: 'Newsletter signups', value: 380, max: 1000, status: 'behind', statusText: 'Behind pace' },
{ label: 'Partner referrals', value: 14, max: 25, status: 'ahead', statusText: 'Ahead of pace' },
],
};
const defaultGoal = {
title: '2026 signup goal', value: 8412, max: 10000, unit: 'signups', status: 'ahead', statusText: '494 ahead of plan',
milestones: [
{ value: 2500, note: 'Reached Mar 14', reached: true },
{ value: 5000, note: 'Reached Jul 2', reached: true },
{ value: 7500, note: 'Reached Sep 21', reached: true },
{ value: 10000, note: '1,588 to go' },
],
};
const n = new Intl.NumberFormat('en-US');
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const card = 'rounded-2xl border border-[#e2e8f0] bg-white p-5 shadow-[0_1px_2px_rgba(15,23,42,.04),0_8px_24px_rgba(15,23,42,.05)]';
const label = 'mb-4 block text-[.72rem] tracking-[.12em] text-[#4b5768] uppercase';
function StatusPill({ status, children }) {
const s = STATUS[status];
return (
<span className={`inline-flex items-center gap-1 rounded-full py-0.5 pr-2 pl-1.5 text-[.74rem] font-bold whitespace-nowrap ${s.cls}`}>
<svg className="size-[13px] flex-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{s.icon}</svg>
{children}
</span>
);
}
function ProgressRing({ ring: r }) {
const titleId = useId();
return (
<article className={`flex min-w-0 items-center gap-4 ${card}`}>
<div className="relative size-24 flex-none" role="progressbar" aria-labelledby={titleId} aria-valuemin={0} aria-valuemax={100} aria-valuenow={r.percent} aria-valuetext={`${r.percent}%, ${r.detail}`}>
<svg className="size-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle className="fill-none stroke-[#e2e8f0] [stroke-width:10]" cx="50" cy="50" r="42" />
<circle className={`fill-none ${r.color} [stroke-width:10] [stroke-linecap:round] transition-[stroke-dasharray] duration-600 ease-in-out motion-reduce:transition-none`} style={{ strokeDasharray: `${r.percent} 100` }} cx="50" cy="50" r="42" pathLength="100" />
</svg>
<span className="absolute inset-0 grid place-items-center text-[1.15rem] font-[750] tabular-nums" aria-hidden="true">{r.percent}%</span>
</div>
<div className="flex min-w-0 flex-col items-start gap-1">
<h3 className="text-base font-bold" id={titleId}>{r.title}</h3>
<p className="text-[.8rem] text-[#4b5768] tabular-nums">{r.detail}</p>
<StatusPill status={r.status}>{r.statusText}</StatusPill>
</div>
</article>
);
}
function TargetBar({ item: t, pace }) {
const labelId = useId();
const fmt = t.format === 'usd' ? usd : n;
const pct = (t.value / t.max) * 100;
return (
<li>
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-2.5 text-[.88rem]">
<b className="mr-auto font-semibold" id={labelId}>{t.label}</b>
<span className="text-[#4b5768] tabular-nums"><strong className="text-[#0f172a]">{fmt.format(t.value)}</strong> of {fmt.format(t.max)}</span>
<StatusPill status={t.status}>{t.statusText}</StatusPill>
</div>
<div
className="relative h-2.5 rounded-full bg-[#e2e8f0] after:absolute after:-inset-y-1 after:left-(--pace) after:w-0.5 after:rounded-sm after:bg-[#0f172a]"
style={{ '--pace': `${pace}%` }}
role="progressbar" aria-labelledby={labelId} aria-valuemin={0} aria-valuemax={t.max} aria-valuenow={t.value}
aria-valuetext={`${fmt.format(t.value)} of ${fmt.format(t.max)}, ${Math.round(pct)}%, ${t.statusText.toLowerCase()}`}
>
<i className="absolute inset-y-0 left-0 rounded-full bg-[#2a78d6] transition-[width] duration-600 motion-reduce:transition-none" style={{ width: `${pct}%` }} />
</div>
</li>
);
}
function GoalCard({ goal: g }) {
const titleId = useId();
const pct = Math.round((g.value / g.max) * 100);
return (
<article className={`flex flex-col gap-3.5 ${card}`}>
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h3 className="text-base font-bold" id={titleId}>{g.title}</h3>
<p className="text-[2.2rem] leading-[1.1] font-[750] tracking-[-.02em] tabular-nums">{n.format(g.value)} <small className="text-base font-semibold tracking-normal text-[#4b5768]">of {n.format(g.max)} {g.unit}</small></p>
</div>
<StatusPill status={g.status}>{g.statusText}</StatusPill>
</div>
<div className="relative">
<progress className="block h-3.5 w-full appearance-none overflow-hidden rounded-full border-0 bg-[#e2e8f0] [&::-moz-progress-bar]:rounded-full [&::-moz-progress-bar]:bg-[#2a78d6] [&::-webkit-progress-bar]:bg-[#e2e8f0] [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-[#2a78d6]" max={g.max} value={g.value} aria-labelledby={titleId} aria-valuetext={`${n.format(g.value)} of ${n.format(g.max)} ${g.unit}, ${pct}%`}>{pct}%</progress>
<span className="pointer-events-none absolute inset-0 bg-[linear-gradient(90deg,transparent_calc(25%-1px),#fff_calc(25%-1px)_calc(25%+1px),transparent_0_calc(50%-1px),#fff_0_calc(50%+1px),transparent_0_calc(75%-1px),#fff_0_calc(75%+1px),transparent_0)]" aria-hidden="true" />
</div>
<ol className="grid grid-cols-2 gap-2.5 sm:grid-cols-4" aria-label="Milestones">
{g.milestones.map((m) => (
<li key={m.value} className={`flex flex-col items-end gap-0.5 border-t-2 pt-2.5 text-right text-[.8rem] text-[#4b5768] ${m.reached ? 'border-[#2a78d6]' : 'border-[#e2e8f0]'}`}>
<strong className="flex items-center gap-[5px] text-[.95rem] text-[#0f172a] tabular-nums">
{m.reached && (
<svg className="size-[15px] text-[#15803d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
)}
{n.format(m.value)}
</strong>
{m.note}
</li>
))}
</ol>
</article>
);
}
export default function ProgressGoals({ rings = defaultRings, targets = defaultTargets, goal = defaultGoal }) {
return (
<div className="flex flex-col gap-[46px]">
<div><span className={label}>Radial progress rings</span>
<div className="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4">
{rings.map((r) => <ProgressRing key={r.title} ring={r} />)}
</div>
</div>
<div><span className={label}>Target bars with a pace marker</span>
<article className={`flex flex-col gap-[18px] ${card}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div><h3 className="text-base font-bold">{targets.title}</h3><p className="text-[.8rem] text-[#4b5768]">{targets.subtitle}</p></div>
<p className="flex items-center gap-2 text-[.78rem] text-[#4b5768]"><i className="h-3.5 w-0.5 rounded-sm bg-[#0f172a]" aria-hidden="true" />Marker: where you should be today ({targets.pace}%)</p>
</div>
<ul className="flex flex-col gap-[18px]">
{targets.items.map((t) => <TargetBar key={t.label} item={t} pace={targets.pace} />)}
</ul>
</article>
</div>
<div><span className={label}>Goal with milestones</span>
<GoalCard goal={goal} />
</div>
</div>
);
}