Web InnoventixFreeCode

Progress Stat

Original · free

stats with progress rings

byWeb InnoventixReact + Tailwind
statxprogressstats
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/statx-progress.json
statx-progress.tsx
"use client";

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

type Accent = "emerald" | "sky" | "violet" | "amber" | "indigo";
type PeriodId = "7d" | "30d" | "q";

type Metric = {
  id: string;
  label: string;
  detail: string;
  accent: Exclude<Accent, "indigo">;
  target: number;
  values: Record<PeriodId, number>;
  prior: Record<PeriodId, number>;
};

const PERIODS: { id: PeriodId; short: string; long: string }[] = [
  { id: "7d", short: "7 days", long: "Last 7 days" },
  { id: "30d", short: "30 days", long: "Last 30 days" },
  { id: "q", short: "Quarter", long: "This quarter" },
];

const METRICS: Metric[] = [
  {
    id: "deploy",
    label: "Deployment success",
    detail:
      "Production releases that shipped clean, with no rollback or hotfix in the first hour after cutover.",
    accent: "emerald",
    target: 98,
    values: { "7d": 94, "30d": 96, q: 91 },
    prior: { "7d": 90, "30d": 93, q: 89 },
  },
  {
    id: "sprint",
    label: "Sprint completion",
    detail:
      "Committed story points delivered inside the planned iteration window across all four squads.",
    accent: "sky",
    target: 90,
    values: { "7d": 88, "30d": 82, q: 79 },
    prior: { "7d": 85, "30d": 86, q: 81 },
  },
  {
    id: "csat",
    label: "Customer satisfaction",
    detail:
      "Average post-resolution rating across 1,204 support conversations, weighted by account tier.",
    accent: "violet",
    target: 95,
    values: { "7d": 92, "30d": 90, q: 93 },
    prior: { "7d": 90, "30d": 91, q: 90 },
  },
  {
    id: "revenue",
    label: "Revenue vs goal",
    detail:
      "Booked annual recurring revenue measured against the committed quarterly bookings plan.",
    accent: "amber",
    target: 100,
    values: { "7d": 71, "30d": 76, q: 68 },
    prior: { "7d": 66, "30d": 70, q: 64 },
  },
];

const ACCENT: Record<
  Accent,
  { arc: string; text: string; glow: string; selBg: string; selRing: string }
> = {
  emerald: {
    arc: "stroke-emerald-500",
    text: "text-emerald-600 dark:text-emerald-400",
    glow: "bg-emerald-400/20 dark:bg-emerald-500/25",
    selBg: "bg-emerald-50 dark:bg-emerald-500/10",
    selRing: "ring-emerald-400/70 dark:ring-emerald-500/50",
  },
  sky: {
    arc: "stroke-sky-500",
    text: "text-sky-600 dark:text-sky-400",
    glow: "bg-sky-400/20 dark:bg-sky-500/25",
    selBg: "bg-sky-50 dark:bg-sky-500/10",
    selRing: "ring-sky-400/70 dark:ring-sky-500/50",
  },
  violet: {
    arc: "stroke-violet-500",
    text: "text-violet-600 dark:text-violet-400",
    glow: "bg-violet-400/20 dark:bg-violet-500/25",
    selBg: "bg-violet-50 dark:bg-violet-500/10",
    selRing: "ring-violet-400/70 dark:ring-violet-500/50",
  },
  amber: {
    arc: "stroke-amber-500",
    text: "text-amber-600 dark:text-amber-400",
    glow: "bg-amber-400/20 dark:bg-amber-500/25",
    selBg: "bg-amber-50 dark:bg-amber-500/10",
    selRing: "ring-amber-400/70 dark:ring-amber-500/50",
  },
  indigo: {
    arc: "stroke-indigo-500",
    text: "text-indigo-600 dark:text-indigo-400",
    glow: "bg-indigo-400/20 dark:bg-indigo-500/25",
    selBg: "bg-indigo-50 dark:bg-indigo-500/10",
    selRing: "ring-indigo-400/70 dark:ring-indigo-500/50",
  },
};

const KEYFRAMES = `
@keyframes statxp-rise {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: translateY(0); }
}
@keyframes statxp-ping {
  0%, 100% { opacity: 0.55; transform: scale(1); }
  50% { opacity: 0; transform: scale(2.1); }
}
.statxp-rise { animation: statxp-rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
.statxp-ping { animation: statxp-ping 2.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .statxp-rise, .statxp-ping { animation: none !important; }
}
`;

function CountUp({ value, reduced }: { value: number; reduced: boolean }) {
  const [shown, setShown] = useState(value);
  const currentRef = useRef(value);
  useEffect(() => {
    const controls = animate(currentRef.current, value, {
      duration: reduced ? 0 : 1.1,
      ease: [0.22, 1, 0.36, 1],
      onUpdate: (latest: number) => {
        currentRef.current = latest;
        setShown(latest);
      },
    });
    return () => controls.stop();
  }, [value, reduced]);
  return <span className="tabular-nums">{Math.round(shown)}</span>;
}

function Delta({ value }: { value: number }) {
  const up = value > 0;
  const down = value < 0;
  const tone = up
    ? "text-emerald-700 dark:text-emerald-300 bg-emerald-500/10 ring-emerald-500/20"
    : down
      ? "text-rose-700 dark:text-rose-300 bg-rose-500/10 ring-rose-500/20"
      : "text-slate-600 dark:text-slate-300 bg-slate-500/10 ring-slate-500/20";
  return (
    <span
      className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ${tone}`}
    >
      <svg
        viewBox="0 0 10 10"
        aria-hidden="true"
        className={`h-2.5 w-2.5 ${down ? "rotate-180" : ""} ${
          !up && !down ? "hidden" : ""
        }`}
      >
        <path d="M5 1.5 9 8H1z" fill="currentColor" />
      </svg>
      <span>
        {up ? "+" : ""}
        {value} pts
      </span>
      <span className="sr-only"> versus prior period</span>
    </span>
  );
}

function ProgressRing({
  value,
  target,
  showTarget,
  accentArc,
  size,
  stroke,
  reduced,
  ariaLabel,
  children,
}: {
  value: number;
  target: number;
  showTarget: boolean;
  accentArc: string;
  size: number;
  stroke: number;
  reduced: boolean;
  ariaLabel?: string;
  children?: ReactNode;
}) {
  const r = (size - stroke) / 2;
  const c = 2 * Math.PI * r;
  const v = Math.max(0, Math.min(100, value));
  const offset = c * (1 - v / 100);
  const t = Math.max(0, Math.min(100, target));
  const ang = (t / 100) * 2 * Math.PI - Math.PI / 2;
  const tx = size / 2 + r * Math.cos(ang);
  const ty = size / 2 + r * Math.sin(ang);
  return (
    <div
      className="relative shrink-0"
      style={{ width: size, height: size }}
      role={ariaLabel ? "img" : undefined}
      aria-label={ariaLabel}
      aria-hidden={ariaLabel ? undefined : true}
    >
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
        <circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          strokeWidth={stroke}
          className="stroke-slate-200 dark:stroke-slate-800"
        />
        <g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
          <motion.circle
            cx={size / 2}
            cy={size / 2}
            r={r}
            fill="none"
            strokeWidth={stroke}
            strokeLinecap="round"
            className={accentArc}
            strokeDasharray={c}
            initial={{ strokeDashoffset: c }}
            animate={{ strokeDashoffset: offset }}
            transition={
              reduced ? { duration: 0 } : { duration: 1.1, ease: [0.22, 1, 0.36, 1] }
            }
          />
        </g>
        {showTarget && (
          <circle
            cx={tx}
            cy={ty}
            r={Math.max(2.5, stroke * 0.42)}
            className="fill-slate-900 stroke-white dark:fill-white dark:stroke-slate-900"
            strokeWidth={size > 100 ? 2.5 : 1.5}
          />
        )}
      </svg>
      {children ? (
        <div className="absolute inset-0 flex items-center justify-center">{children}</div>
      ) : null}
    </div>
  );
}

export default function StatxProgress() {
  const reduced = !!useReducedMotion();
  const uid = useId();
  const [period, setPeriod] = useState<PeriodId>("30d");
  const [showTargets, setShowTargets] = useState(false);
  const [focusedId, setFocusedId] = useState<string | null>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  function onTabKey(e: KeyboardEvent<HTMLButtonElement>, index: number) {
    const count = PERIODS.length;
    let next = index;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % count;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      next = (index - 1 + count) % count;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = count - 1;
    else return;
    e.preventDefault();
    setPeriod(PERIODS[next].id);
    tabRefs.current[next]?.focus();
  }

  const periodLong = PERIODS.find((p) => p.id === period)?.long ?? "";
  const overallValue = Math.round(
    METRICS.reduce((s, m) => s + m.values[period], 0) / METRICS.length,
  );
  const overallPrior = Math.round(
    METRICS.reduce((s, m) => s + m.prior[period], 0) / METRICS.length,
  );
  const overallTarget = Math.round(
    METRICS.reduce((s, m) => s + m.target, 0) / METRICS.length,
  );
  const focused = focusedId ? METRICS.find((m) => m.id === focusedId) ?? null : null;
  const heroValue = focused ? focused.values[period] : overallValue;
  const heroPrior = focused ? focused.prior[period] : overallPrior;
  const heroTarget = focused ? focused.target : overallTarget;
  const heroAccent: Accent = focused ? focused.accent : "indigo";
  const heroLabel = focused ? focused.label : "Overall health";
  const heroDetail = focused
    ? focused.detail
    : "A blended index across deployment reliability, delivery pace, customer sentiment and revenue pacing for the selected window.";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 py-20 sm:py-28 dark:bg-slate-950">
      <style>{KEYFRAMES}</style>
      <div className="relative mx-auto max-w-6xl px-6">
        <div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
          <div className="max-w-xl">
            <div className="mb-4 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
              <span className="relative flex h-2 w-2">
                <span className="statxp-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400" />
                <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
              </span>
              Live · synced 3 min ago
            </div>
            <h2 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
              Performance at a glance
            </h2>
            <p className="mt-3 text-base text-slate-600 dark:text-slate-400">
              Health metrics for the platform team. Change the reporting window, pin any
              metric to compare it against the aggregate, and toggle quarterly targets onto
              the rings.
            </p>
          </div>

          <div className="flex flex-wrap items-center gap-5">
            <div
              role="tablist"
              aria-label="Reporting window"
              className="inline-flex rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-900"
            >
              {PERIODS.map((p, i) => {
                const selected = p.id === period;
                return (
                  <button
                    key={p.id}
                    ref={(el) => {
                      tabRefs.current[i] = el;
                    }}
                    id={`${uid}-tab-${p.id}`}
                    role="tab"
                    type="button"
                    aria-selected={selected}
                    aria-controls={`${uid}-panel`}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setPeriod(p.id)}
                    onKeyDown={(e) => onTabKey(e, i)}
                    className={`rounded-full px-3.5 py-1.5 text-sm font-medium transition 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-offset-slate-900 ${
                      selected
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                        : "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
                    }`}
                  >
                    {p.short}
                  </button>
                );
              })}
            </div>

            <div className="flex items-center gap-2.5">
              <span
                id={`${uid}-switch`}
                className="text-sm font-medium text-slate-600 dark:text-slate-300"
              >
                Targets
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={showTargets}
                aria-labelledby={`${uid}-switch`}
                onClick={() => setShowTargets((v) => !v)}
                className={`relative inline-flex h-6 w-11 items-center rounded-full transition 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-offset-slate-950 ${
                  showTargets ? "bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
                    showTargets ? "translate-x-5" : "translate-x-0.5"
                  }`}
                />
              </button>
            </div>
          </div>
        </div>

        <div
          id={`${uid}-panel`}
          role="tabpanel"
          aria-labelledby={`${uid}-tab-${period}`}
          tabIndex={0}
          className="mt-8 rounded-3xl 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-offset-slate-950"
        >
          <div className="statxp-rise grid gap-10 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] dark:border-slate-800 dark:bg-slate-900">
            <div className="relative flex flex-col items-center justify-center gap-4">
              <div
                aria-hidden="true"
                className={`pointer-events-none absolute top-4 h-44 w-44 rounded-full blur-3xl ${ACCENT[heroAccent].glow}`}
              />
              <span
                className={`relative text-xs font-semibold uppercase tracking-wider ${ACCENT[heroAccent].text}`}
              >
                {focused ? "Focused metric" : "Aggregate score"}
              </span>
              <ProgressRing
                size={208}
                stroke={16}
                value={heroValue}
                target={heroTarget}
                showTarget={showTargets}
                accentArc={ACCENT[heroAccent].arc}
                reduced={reduced}
                ariaLabel={`${heroLabel}: ${heroValue} percent, against a ${heroTarget} percent target for ${periodLong}`}
              >
                <div className="flex flex-col items-center">
                  <div className="flex items-start text-5xl font-bold tracking-tight text-slate-900 dark:text-white">
                    <CountUp value={heroValue} reduced={reduced} />
                    <span className="mt-1 text-2xl text-slate-400 dark:text-slate-500">
                      %
                    </span>
                  </div>
                  <div className="mt-1 max-w-[7rem] text-center text-xs font-medium text-slate-500 dark:text-slate-400">
                    {heroLabel}
                  </div>
                </div>
              </ProgressRing>
              <div className="relative flex items-center gap-2">
                <Delta value={heroValue - heroPrior} />
                <span className="text-xs text-slate-500 dark:text-slate-400">
                  vs prior {periodLong.toLowerCase()}
                </span>
              </div>
              <p className="relative max-w-xs text-center text-sm text-slate-600 dark:text-slate-400">
                {heroDetail}
              </p>
              <p className="relative text-xs font-medium text-slate-400 dark:text-slate-500">
                Target {heroTarget}%{focused ? "" : " (blended)"}
              </p>
            </div>

            <div
              role="group"
              aria-label="Platform metrics — activate a metric to focus it in the ring"
              className="flex flex-col justify-center gap-2"
            >
              {METRICS.map((m, i) => {
                const v = m.values[period];
                const d = v - m.prior[period];
                const on = focusedId === m.id;
                const a = ACCENT[m.accent];
                return (
                  <button
                    key={m.id}
                    type="button"
                    aria-pressed={on}
                    aria-label={`${m.label}: ${v} percent, ${
                      d >= 0 ? "up" : "down"
                    } ${Math.abs(d)} points. ${
                      on ? "Focused, activate to clear." : "Activate to focus in the ring."
                    }`}
                    onClick={() => setFocusedId((prev) => (prev === m.id ? null : m.id))}
                    style={{ animationDelay: `${140 + i * 70}ms` }}
                    className={`statxp-rise group flex w-full items-center gap-4 rounded-2xl border px-3 py-3 text-left transition 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-offset-slate-900 ${
                      on
                        ? `${a.selBg} border-transparent ring-2 ${a.selRing}`
                        : "border-slate-200 bg-slate-50 hover:bg-slate-100 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:bg-slate-800/60"
                    }`}
                  >
                    <ProgressRing
                      size={46}
                      stroke={6}
                      value={v}
                      target={m.target}
                      showTarget={showTargets}
                      accentArc={a.arc}
                      reduced={reduced}
                    />
                    <div className="min-w-0 flex-1">
                      <div className="flex items-center justify-between gap-3">
                        <span className="truncate text-sm font-semibold text-slate-900 dark:text-white">
                          {m.label}
                        </span>
                        <span className="shrink-0 text-sm font-bold tabular-nums text-slate-900 dark:text-white">
                          {v}%
                        </span>
                      </div>
                      <div className="mt-1 flex items-center gap-2">
                        <Delta value={d} />
                        <span className="truncate text-xs text-slate-400 dark:text-slate-500">
                          target {m.target}%
                        </span>
                      </div>
                    </div>
                    <span
                      aria-hidden="true"
                      className="shrink-0 text-slate-300 transition group-hover:text-slate-400 dark:text-slate-600 dark:group-hover:text-slate-500"
                    >
                      {on ? (
                        <svg
                          viewBox="0 0 20 20"
                          className={`h-5 w-5 ${a.text}`}
                          fill="currentColor"
                        >
                          <path
                            fillRule="evenodd"
                            d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.7-9.3a1 1 0 00-1.4-1.4L9 10.6 7.7 9.3a1 1 0 10-1.4 1.4l2 2a1 1 0 001.4 0l4-4z"
                            clipRule="evenodd"
                          />
                        </svg>
                      ) : (
                        <svg
                          viewBox="0 0 20 20"
                          className="h-5 w-5"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="2"
                        >
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M7.5 5l5 5-5 5"
                          />
                        </svg>
                      )}
                    </span>
                  </button>
                );
              })}
            </div>
          </div>
        </div>

        <p className="mt-6 text-center text-xs text-slate-400 dark:text-slate-500">
          Figures are illustrative platform telemetry for the {periodLong.toLowerCase()}{" "}
          window. Use the arrow keys on the window switcher to move between periods.
        </p>
      </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 →
Four Stat Metric Band

Four Stat Metric Band

Original

A four-column metric band with gradient figures inside a hairline-divided card, ideal for headline numbers like uptime, latency and growth.

Labelled Stats With Dividers

Labelled Stats With Dividers

Original

Three centred stats separated by responsive dividers, each pairing a headline figure with an uppercase label and supporting detail.

Trusted By Logo Cloud

Trusted By Logo Cloud

Original

A responsive six-up logo strip using simple inline-SVG marks and wordmarks with a grayscale-to-colour hover, for a trusted-by section.

Hero Stat With Supporting Copy

Hero Stat With Supporting Copy

Original

A two-column layout pairing one oversized hero statistic with a supporting paragraph and a checklist card explaining the number.

Count-up stats reveal

Count-up stats reveal

Original

A four-card stat band whose gradient numbers count up from zero as the section scrolls into view, with staggered blur-in cards, a pulsing eyebrow, floating aurora blobs and a shimmering underline sweep.

Animated SVG progress rings

Animated SVG progress rings

Original

Four circular SVG progress rings that draw their gradient arcs and count up to their target percentage the moment they enter view, over a slowly rotating conic halo with a soft glow pulse behind each ring.

Staggered stat band with marquee ticker

Staggered stat band with marquee ticker

Original

A dark stat band where 3D-tilt cards stagger into view with count-up figures over an animated gradient mesh, finished by an infinite marquee ticker of highlight chips that pauses on hover.

Counter Row Stat

Counter Row Stat

Original

animated counter stats row

Cards Stat

Cards Stat

Original

stat cards with icons

Gradient Stat

Gradient Stat

Original

gradient stats band

Split Stat

Split Stat

Original

stats beside copy

Icons Stat

Icons Stat

Original

icon stat grid