Web InnoventixFreeCode

Counter Row Stat

Original · free

animated counter stats row

byWeb InnoventixReact + Tailwind
statxcounterrowstats
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-counter-row.json
statx-counter-row.tsx
"use client";

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

type Tint = "indigo" | "sky" | "emerald" | "violet";

type Metric = {
  id: string;
  label: string;
  sublabel: string;
  icon: "layers" | "gauge" | "repeat" | "trending";
  tint: Tint;
  prefix?: string;
  suffix?: string;
  decimals?: number;
};

type Datum = { value: number; delta: number; trend: number[] };

type Period = {
  id: string;
  label: string;
  data: Record<string, Datum>;
};

const METRICS: Metric[] = [
  { id: "shipped", label: "Products shipped", sublabel: "web & mobile, end to end", icon: "layers", tint: "indigo", decimals: 0 },
  { id: "lighthouse", label: "Avg. Lighthouse", sublabel: "performance, mobile", icon: "gauge", tint: "sky", suffix: "/100", decimals: 0 },
  { id: "retention", label: "Client retention", sublabel: "renewed engagements", icon: "repeat", tint: "emerald", suffix: "%", decimals: 0 },
  { id: "pipeline", label: "Pipeline influenced", sublabel: "sourced to closed", icon: "trending", tint: "violet", prefix: "$", suffix: "M", decimals: 1 },
];

const PERIODS: Period[] = [
  {
    id: "q",
    label: "Last 90 days",
    data: {
      shipped: { value: 18, delta: 12.5, trend: [8, 10, 9, 12, 14, 15, 18] },
      lighthouse: { value: 98, delta: 2.1, trend: [93, 94, 95, 95, 96, 97, 98] },
      retention: { value: 96, delta: 3.2, trend: [88, 90, 91, 93, 94, 95, 96] },
      pipeline: { value: 2.4, delta: 18.4, trend: [1.2, 1.5, 1.7, 1.9, 2.0, 2.2, 2.4] },
    },
  },
  {
    id: "y",
    label: "Last 12 months",
    data: {
      shipped: { value: 74, delta: 9.6, trend: [40, 48, 52, 58, 63, 69, 74] },
      lighthouse: { value: 97, delta: 4.0, trend: [90, 91, 93, 94, 95, 96, 97] },
      retention: { value: 94, delta: 1.8, trend: [86, 88, 90, 91, 92, 93, 94] },
      pipeline: { value: 11.6, delta: 22.0, trend: [5, 6.5, 7.8, 8.9, 9.7, 10.6, 11.6] },
    },
  },
  {
    id: "all",
    label: "Since 2019",
    data: {
      shipped: { value: 312, delta: 6.4, trend: [120, 160, 200, 235, 265, 290, 312] },
      lighthouse: { value: 96, delta: 5.0, trend: [82, 86, 89, 91, 93, 95, 96] },
      retention: { value: 93, delta: 2.2, trend: [80, 83, 86, 88, 90, 92, 93] },
      pipeline: { value: 48.9, delta: 14.7, trend: [12, 19, 26, 33, 39, 44, 48.9] },
    },
  },
];

const TINTS: Record<Tint, { icon: string; chip: string; spark: string; hover: string }> = {
  indigo: {
    icon: "text-indigo-600 dark:text-indigo-400",
    chip: "bg-indigo-50 ring-indigo-100 dark:bg-indigo-500/10 dark:ring-indigo-400/20",
    spark: "text-indigo-500 dark:text-indigo-400",
    hover: "hover:border-indigo-300 dark:hover:border-indigo-400/40",
  },
  sky: {
    icon: "text-sky-600 dark:text-sky-400",
    chip: "bg-sky-50 ring-sky-100 dark:bg-sky-500/10 dark:ring-sky-400/20",
    spark: "text-sky-500 dark:text-sky-400",
    hover: "hover:border-sky-300 dark:hover:border-sky-400/40",
  },
  emerald: {
    icon: "text-emerald-600 dark:text-emerald-400",
    chip: "bg-emerald-50 ring-emerald-100 dark:bg-emerald-500/10 dark:ring-emerald-400/20",
    spark: "text-emerald-500 dark:text-emerald-400",
    hover: "hover:border-emerald-300 dark:hover:border-emerald-400/40",
  },
  violet: {
    icon: "text-violet-600 dark:text-violet-400",
    chip: "bg-violet-50 ring-violet-100 dark:bg-violet-500/10 dark:ring-violet-400/20",
    spark: "text-violet-500 dark:text-violet-400",
    hover: "hover:border-violet-300 dark:hover:border-violet-400/40",
  },
};

function formatNumber(value: number, decimals: number): string {
  return value.toLocaleString("en-US", {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  });
}

function useCountUp(
  target: number,
  active: boolean,
  restartKey: number,
  reduced: boolean,
  duration: number,
  delay: number,
): number {
  const [value, setValue] = useState<number>(0);

  useEffect(() => {
    if (reduced) {
      setValue(target);
      return;
    }
    if (!active) {
      setValue(0);
      return;
    }
    let raf = 0;
    let startTs = 0;
    setValue(0);
    const tick = (ts: number) => {
      if (startTs === 0) startTs = ts;
      const elapsed = ts - startTs - delay;
      if (elapsed < 0) {
        raf = requestAnimationFrame(tick);
        return;
      }
      const p = Math.min(elapsed / duration, 1);
      const eased = p >= 1 ? 1 : 1 - Math.pow(2, -10 * p);
      setValue(target * eased);
      if (p < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [target, active, restartKey, reduced, duration, delay]);

  return value;
}

function Icon({ name, className }: { name: Metric["icon"]; className?: string }) {
  const shared = {
    viewBox: "0 0 24 24",
    fill: "none" as const,
    stroke: "currentColor",
    strokeWidth: 1.7,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    className,
    "aria-hidden": true,
  };
  switch (name) {
    case "layers":
      return (
        <svg {...shared}>
          <path d="M12 3 3 8l9 5 9-5-9-5Z" />
          <path d="M3 16l9 5 9-5" />
          <path d="M3 12l9 5 9-5" />
        </svg>
      );
    case "gauge":
      return (
        <svg {...shared}>
          <path d="M4 18a8 8 0 1 1 16 0" />
          <path d="M12 18l4-4" />
          <path d="M12 18h.01" />
        </svg>
      );
    case "repeat":
      return (
        <svg {...shared}>
          <path d="M17 2l4 4-4 4" />
          <path d="M21 6H9a5 5 0 0 0-5 5" />
          <path d="M7 22l-4-4 4-4" />
          <path d="M3 18h12a5 5 0 0 0 5-5" />
        </svg>
      );
    case "trending":
    default:
      return (
        <svg {...shared}>
          <path d="M3 17l6-6 4 4 8-8" />
          <path d="M21 7v6" />
          <path d="M21 7h-6" />
        </svg>
      );
  }
}

function Sparkline({ points, className }: { points: number[]; className?: string }) {
  const w = 104;
  const h = 34;
  const pad = 3;
  const min = Math.min(...points);
  const max = Math.max(...points);
  const range = max - min || 1;
  const step = (w - pad * 2) / (points.length - 1);
  const coords = points.map((p, i) => {
    const x = pad + i * step;
    const y = pad + (h - pad * 2) * (1 - (p - min) / range);
    return [x, y] as const;
  });
  const d = coords
    .map((c, i) => `${i === 0 ? "M" : "L"}${c[0].toFixed(1)},${c[1].toFixed(1)}`)
    .join(" ");
  const area = `${d} L${coords[coords.length - 1][0].toFixed(1)},${h} L${coords[0][0].toFixed(1)},${h} Z`;
  let length = 0;
  for (let i = 1; i < coords.length; i++) {
    length += Math.hypot(coords[i][0] - coords[i - 1][0], coords[i][1] - coords[i - 1][1]);
  }
  const last = coords[coords.length - 1];
  const gradId = `statx-fill-${points.join("-")}`;

  return (
    <svg
      viewBox={`0 0 ${w} ${h}`}
      className={className}
      width={w}
      height={h}
      preserveAspectRatio="none"
      aria-hidden="true"
    >
      <defs>
        <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="currentColor" stopOpacity="0.22" />
          <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
        </linearGradient>
      </defs>
      <path d={area} fill={`url(#${gradId})`} stroke="none" />
      <path
        d={d}
        className="statx-spark"
        fill="none"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
        style={{ strokeDasharray: length, strokeDashoffset: length }}
      />
      <circle cx={last[0]} cy={last[1]} r={2.6} fill="currentColor" />
    </svg>
  );
}

function DeltaBadge({ delta }: { delta: number }) {
  const up = delta >= 0;
  return (
    <span
      className={
        "inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums " +
        (up
          ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400"
          : "bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400")
      }
    >
      <svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        {up ? <path d="M6 9.5V2.5M6 2.5 2.5 6M6 2.5 9.5 6" /> : <path d="M6 2.5v7M6 9.5 2.5 6M6 9.5 9.5 6" />}
      </svg>
      {up ? "+" : "−"}
      {Math.abs(delta).toFixed(1)}%
    </span>
  );
}

function StatCard({
  metric,
  datum,
  index,
  active,
  restartKey,
  reduced,
}: {
  metric: Metric;
  datum: Datum;
  index: number;
  active: boolean;
  restartKey: number;
  reduced: boolean;
}) {
  const decimals = metric.decimals ?? 0;
  const value = useCountUp(datum.value, active, restartKey, reduced, 1500, index * 110);
  const shown = formatNumber(value, decimals);
  const final = formatNumber(datum.value, decimals);
  const tint = TINTS[metric.tint];

  return (
    <motion.div
      initial={reduced ? false : { opacity: 0, y: 18 }}
      animate={reduced ? undefined : active ? { opacity: 1, y: 0 } : { opacity: 0, y: 18 }}
      transition={{ duration: 0.55, delay: index * 0.08, ease: [0.16, 1, 0.3, 1] }}
      className={
        "group relative flex flex-col justify-between rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm backdrop-blur-sm transition-colors sm:p-6 " +
        "dark:border-slate-800 dark:bg-slate-900/60 " +
        tint.hover
      }
    >
      <div className="flex items-start justify-between gap-3">
        <span className={"inline-flex h-10 w-10 items-center justify-center rounded-xl ring-1 " + tint.chip}>
          <Icon name={metric.icon} className={"h-5 w-5 " + tint.icon} />
        </span>
        <DeltaBadge delta={datum.delta} />
      </div>

      <div className="mt-6">
        <div className="flex items-end gap-1">
          <span className="text-4xl font-semibold tracking-tight text-slate-900 tabular-nums sm:text-[2.75rem] sm:leading-none dark:text-white" aria-hidden="true">
            {metric.prefix ? <span className={"mr-0.5 align-top text-2xl " + tint.icon}>{metric.prefix}</span> : null}
            {shown}
            {metric.suffix ? <span className="ml-0.5 text-lg font-medium text-slate-400 dark:text-slate-500">{metric.suffix}</span> : null}
          </span>
        </div>
        <span className="sr-only">
          {metric.label}: {metric.prefix ?? ""}
          {final}
          {metric.suffix ?? ""}, {datum.delta >= 0 ? "up" : "down"} {Math.abs(datum.delta).toFixed(1)} percent versus the previous period.
        </span>

        <div className="mt-3 flex items-center justify-between gap-4">
          <div>
            <p className="text-sm font-semibold text-slate-700 dark:text-slate-200">{metric.label}</p>
            <p className="text-xs text-slate-500 dark:text-slate-400">{metric.sublabel}</p>
          </div>
          <Sparkline
            key={restartKey}
            points={datum.trend}
            className={"h-8 w-[104px] shrink-0 " + tint.spark}
          />
        </div>
      </div>
    </motion.div>
  );
}

export default function StatxCounterRow() {
  const reduced = useReducedMotion() ?? false;
  const gridRef = useRef<HTMLDivElement>(null);
  const inView = useInView(gridRef, { once: true, amount: 0.3 });
  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const [periodId, setPeriodId] = useState<string>(PERIODS[0].id);
  const [restartKey, setRestartKey] = useState<number>(0);

  const activeIndex = Math.max(0, PERIODS.findIndex((p) => p.id === periodId));
  const activePeriod = PERIODS[activeIndex];
  const active = inView;

  const selectPeriod = (id: string) => {
    setPeriodId(id);
    setRestartKey((k) => k + 1);
  };

  const replay = () => setRestartKey((k) => k + 1);

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    let next = activeIndex;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (activeIndex + 1) % PERIODS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (activeIndex - 1 + PERIODS.length) % PERIODS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = PERIODS.length - 1;
    else return;
    e.preventDefault();
    selectPeriod(PERIODS[next].id);
    optionRefs.current[next]?.focus();
  };

  const liveSummary = METRICS.map((m) => {
    const d = activePeriod.data[m.id];
    return `${m.label} ${m.prefix ?? ""}${formatNumber(d.value, m.decimals ?? 0)}${m.suffix ?? ""}`;
  }).join(", ");

  return (
    <section
      aria-labelledby="statx-heading"
      className="relative w-full overflow-hidden bg-gradient-to-b from-white to-slate-50 px-5 py-20 sm:px-8 sm:py-24 dark:from-slate-950 dark:to-slate-900"
    >
      <style>{`
        .statx-spark { animation: statx-draw 1.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
        @keyframes statx-draw { to { stroke-dashoffset: 0; } }
        .statx-ring { animation: statx-ring 2.6s ease-in-out infinite; }
        @keyframes statx-ring {
          0%, 100% { opacity: 0.55; transform: scale(1); }
          50% { opacity: 0; transform: scale(2.1); }
        }
        @media (prefers-reduced-motion: reduce) {
          .statx-spark, .statx-ring { animation: none !important; }
          .statx-spark { stroke-dashoffset: 0 !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10"
      />

      <div className="relative mx-auto max-w-6xl">
        <div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
          <div className="max-w-xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
              <span className="relative flex h-2 w-2">
                <span className="statx-ring absolute inset-0 rounded-full bg-emerald-500" />
                <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
              </span>
              Live studio data
            </span>
            <h2
              id="statx-heading"
              className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white"
            >
              The numbers behind every build
            </h2>
            <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
              Outcomes we actually track across the studio, pulled from live project data instead of a pitch deck. Switch the window to see how the work compounds.
            </p>
          </div>

          <div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
            <div
              role="radiogroup"
              aria-label="Choose a time window"
              onKeyDown={onKeyDown}
              className="inline-flex rounded-full border border-slate-200 bg-slate-100/80 p-1 dark:border-slate-800 dark:bg-slate-800/60"
            >
              {PERIODS.map((p, i) => {
                const selected = p.id === periodId;
                return (
                  <button
                    key={p.id}
                    ref={(el) => {
                      optionRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={selected}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => selectPeriod(p.id)}
                    className={
                      "relative rounded-full 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-offset-slate-800 " +
                      (selected
                        ? "text-slate-900 dark:text-white"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200")
                    }
                  >
                    {selected ? (
                      <motion.span
                        layoutId="statx-seg"
                        className="absolute inset-0 rounded-full bg-white shadow-sm ring-1 ring-black/5 dark:bg-slate-950 dark:ring-white/10"
                        transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 32 }}
                      />
                    ) : null}
                    <span className="relative z-10">{p.label}</span>
                  </button>
                );
              })}
            </div>

            <button
              type="button"
              onClick={replay}
              className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:border-slate-300 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:text-white dark:focus-visible:ring-offset-slate-950"
            >
              <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M21 12a9 9 0 1 1-2.64-6.36" />
                <path d="M21 4v5h-5" />
              </svg>
              Replay
            </button>
          </div>
        </div>

        <div
          ref={gridRef}
          className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 lg:gap-5"
        >
          {METRICS.map((metric, i) => (
            <StatCard
              key={metric.id}
              metric={metric}
              datum={activePeriod.data[metric.id]}
              index={i}
              active={active}
              restartKey={restartKey}
              reduced={reduced}
            />
          ))}
        </div>

        <p className="mt-8 text-xs text-slate-400 dark:text-slate-500">
          Figures reflect delivered client engagements for {activePeriod.label.toLowerCase()}. Deltas compare against the prior equivalent window.
        </p>

        <p className="sr-only" aria-live="polite">
          Showing {activePeriod.label}. {liveSummary}.
        </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.

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

Progress Stat

Progress Stat

Original

stats with progress rings