Web InnoventixFreeCode

Chart Progress

Original · free

horizontal progress/metric chart

byWeb InnoventixReact + Tailwind
chartprogresscharts
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-progress.json
chart-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 quote

Similar components

Browse all →