Web InnoventixFreeCode

Split Stat

Original · free

stats beside copy

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

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

type Period = "month" | "year";

interface PeriodOption {
  id: Period;
  label: string;
  caption: string;
}

interface StatItem {
  id: string;
  label: string;
  detail: string;
  values: Record<Period, number>;
  change: Record<Period, number>;
  decimals: number;
  prefix?: string;
  suffix?: string;
  betterWhenUp: boolean;
}

const PERIODS: PeriodOption[] = [
  { id: "month", label: "Last 30 days", caption: "vs. prior 30 days" },
  { id: "year", label: "Last 12 months", caption: "vs. prior 12 months" },
];

const STATS: StatItem[] = [
  {
    id: "deploys",
    label: "Deployments shipped",
    detail: "Every merge to main, promoted to production automatically.",
    values: { month: 1284, year: 15320 },
    change: { month: 18, year: 142 },
    decimals: 0,
    betterWhenUp: true,
  },
  {
    id: "uptime",
    label: "Platform uptime",
    detail: "Measured across all edge regions, excluding scheduled windows.",
    values: { month: 99.98, year: 99.95 },
    change: { month: 0.02, year: 0.11 },
    decimals: 2,
    suffix: "%",
    betterWhenUp: true,
  },
  {
    id: "build",
    label: "Median build time",
    detail: "Cold cache to green check, p50 across all pipelines.",
    values: { month: 42, year: 53 },
    change: { month: -21, year: -12 },
    decimals: 0,
    suffix: "s",
    betterWhenUp: false,
  },
  {
    id: "recovery",
    label: "Mean time to recovery",
    detail: "First alert to full restore, on-call incidents only.",
    values: { month: 6.4, year: 8.9 },
    change: { month: -19, year: -27 },
    decimals: 1,
    suffix: " min",
    betterWhenUp: false,
  },
];

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

function useCountUp(
  target: number,
  decimals: number,
  active: boolean,
  reduce: boolean,
): number {
  const [value, setValue] = useState<number>(reduce ? target : 0);
  const fromRef = useRef<number>(reduce ? target : 0);

  useEffect(() => {
    if (reduce) {
      fromRef.current = target;
      setValue(target);
      return;
    }
    if (!active) {
      fromRef.current = 0;
      setValue(0);
      return;
    }

    const from = fromRef.current;
    const to = target;
    const duration = 950;
    let raf = 0;
    let startTime = 0;

    const tick = (now: number) => {
      if (startTime === 0) startTime = now;
      const progress = Math.min((now - startTime) / duration, 1);
      const eased = 1 - Math.pow(1 - progress, 3);
      const current = from + (to - from) * eased;
      fromRef.current = current;
      setValue(current);
      if (progress < 1) raf = requestAnimationFrame(tick);
    };

    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
    // decimals intentionally excluded: it only affects formatting, not the tween
  }, [target, active, reduce]);

  return value;
}

interface StatCardProps {
  stat: StatItem;
  period: Period;
  active: boolean;
  reduce: boolean;
}

function StatCard({ stat, period, active, reduce }: StatCardProps) {
  const target = stat.values[period];
  const change = stat.change[period];
  const animated = useCountUp(target, stat.decimals, active, reduce);

  const finalText = `${stat.prefix ?? ""}${formatNumber(target, stat.decimals)}${
    stat.suffix ?? ""
  }`;
  const shownText = `${stat.prefix ?? ""}${formatNumber(
    animated,
    stat.decimals,
  )}${stat.suffix ?? ""}`;

  const isPositive = change >= 0;
  const isGood = isPositive === stat.betterWhenUp;
  const changeText = `${isPositive ? "+" : "−"}${formatNumber(
    Math.abs(change),
    stat.decimals > 0 && Math.abs(change) < 1 ? 2 : 0,
  )}%`;

  return (
    <div className="group relative overflow-hidden rounded-2xl border border-slate-200/80 bg-white/70 p-6 shadow-sm ring-1 ring-transparent transition-all duration-300 hover:-translate-y-1 hover:border-indigo-300 hover:shadow-lg hover:shadow-indigo-500/10 dark:border-slate-700/70 dark:bg-slate-900/50 dark:hover:border-indigo-500/60 dark:hover:shadow-indigo-500/10">
      <span
        aria-hidden="true"
        className="pointer-events-none absolute -right-8 -top-10 h-24 w-24 rounded-full bg-gradient-to-br from-indigo-400/20 to-violet-400/0 blur-2xl transition-opacity duration-300 group-hover:opacity-100 dark:from-indigo-500/25"
      />

      <div className="flex items-start justify-between gap-3">
        <p className="text-sm font-medium text-slate-500 dark:text-slate-400">
          {stat.label}
        </p>
        <span
          className={[
            "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
            isGood
              ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300"
              : "bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-300",
          ].join(" ")}
        >
          <svg
            aria-hidden="true"
            viewBox="0 0 12 12"
            className={[
              "h-3 w-3",
              isPositive ? "" : "rotate-180",
            ].join(" ")}
            fill="none"
            stroke="currentColor"
            strokeWidth={1.75}
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="M6 10V2M6 2 2.5 5.5M6 2l3.5 3.5" />
          </svg>
          <span>{changeText}</span>
        </span>
      </div>

      <div className="mt-4">
        <span
          aria-hidden="true"
          className="block bg-gradient-to-br from-slate-900 to-slate-600 bg-clip-text text-4xl font-semibold tabular-nums tracking-tight text-transparent dark:from-white dark:to-slate-300 sm:text-5xl"
        >
          {shownText}
        </span>
        <span className="sr-only">{finalText}</span>
      </div>

      <p className="mt-3 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
        {stat.detail}
      </p>
    </div>
  );
}

const listVariants: Variants = {
  hidden: {},
  show: { transition: { staggerChildren: 0.08 } },
};

const itemVariants: Variants = {
  hidden: { opacity: 0, y: 18 },
  show: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.55, ease: [0.22, 1, 0.36, 1] },
  },
};

export default function StatxSplit() {
  const reduce = useReducedMotion() ?? false;
  const [period, setPeriod] = useState<Period>("month");
  const [started, setStarted] = useState<boolean>(false);

  const sectionRef = useRef<HTMLElement | null>(null);
  const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);

  useEffect(() => {
    if (typeof window === "undefined") return;
    if (!("IntersectionObserver" in window)) {
      setStarted(true);
      return;
    }
    const node = sectionRef.current;
    if (!node) return;

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            setStarted(true);
            observer.disconnect();
            break;
          }
        }
      },
      { threshold: 0.25 },
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  const selectByIndex = useCallback((index: number) => {
    const bounded = (index + PERIODS.length) % PERIODS.length;
    setPeriod(PERIODS[bounded].id);
    optionRefs.current[bounded]?.focus();
  }, []);

  const onKeyDown = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
      switch (event.key) {
        case "ArrowRight":
        case "ArrowDown":
          event.preventDefault();
          selectByIndex(index + 1);
          break;
        case "ArrowLeft":
        case "ArrowUp":
          event.preventDefault();
          selectByIndex(index - 1);
          break;
        case "Home":
          event.preventDefault();
          selectByIndex(0);
          break;
        case "End":
          event.preventDefault();
          selectByIndex(PERIODS.length - 1);
          break;
        default:
          break;
      }
    },
    [selectByIndex],
  );

  const activePeriod = PERIODS.find((p) => p.id === period) ?? PERIODS[0];

  return (
    <section
      ref={sectionRef}
      className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-slate-950 dark:text-slate-100"
    >
      <style>{`
        @keyframes statxsplit-pulse {
          0%, 100% { transform: scale(1); opacity: 0.55; }
          50% { transform: scale(2.6); opacity: 0; }
        }
        .statxsplit-ping {
          animation: statxsplit-pulse 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .statxsplit-ping { animation: none; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 [mask-image:radial-gradient(70%_60%_at_50%_0%,black,transparent)]"
      >
        <div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(99,102,241,0.06)_1px,transparent_1px),linear-gradient(to_bottom,rgba(99,102,241,0.06)_1px,transparent_1px)] bg-[size:44px_44px] dark:bg-[linear-gradient(to_right,rgba(129,140,248,0.08)_1px,transparent_1px),linear-gradient(to_bottom,rgba(129,140,248,0.08)_1px,transparent_1px)]" />
      </div>

      <div className="relative mx-auto grid max-w-6xl grid-cols-1 items-center gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-16">
        <div>
          <div className="inline-flex items-center gap-2.5 rounded-full border border-slate-200 bg-white/80 px-3.5 py-1.5 text-xs font-semibold uppercase tracking-widest text-indigo-600 dark:border-slate-700 dark:bg-slate-900/70 dark:text-indigo-300">
            <span className="relative flex h-2 w-2">
              <span className="statxsplit-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 platform metrics
          </div>

          <h2 className="mt-6 text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white">
            The numbers behind
            <span className="bg-gradient-to-r from-indigo-600 to-violet-600 bg-clip-text text-transparent dark:from-indigo-400 dark:to-violet-400">
              {" "}
              every release
            </span>{" "}
            you ship.
          </h2>

          <p className="mt-5 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-300">
            We instrument the full path from commit to customer, so reliability
            stops being a gut feeling. Switch the window to see how the platform
            has held up over time, not just today.
          </p>

          <div className="mt-8">
            <div
              role="radiogroup"
              aria-label="Choose a reporting window"
              className="inline-flex rounded-xl border border-slate-200 bg-white p-1 shadow-sm dark:border-slate-700 dark:bg-slate-900"
            >
              {PERIODS.map((option, index) => {
                const selected = option.id === period;
                return (
                  <button
                    key={option.id}
                    ref={(el) => {
                      optionRefs.current[index] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={selected}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setPeriod(option.id)}
                    onKeyDown={(event) => onKeyDown(event, index)}
                    className={[
                      "rounded-lg px-4 py-2 text-sm font-medium outline-none transition-colors duration-200 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",
                      selected
                        ? "bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-900"
                        : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white",
                    ].join(" ")}
                  >
                    {option.label}
                  </button>
                );
              })}
            </div>
            <p
              aria-live="polite"
              className="mt-3 text-xs font-medium text-slate-500 dark:text-slate-400"
            >
              Showing {activePeriod.label.toLowerCase()},{" "}
              {activePeriod.caption}.
            </p>
          </div>

          <a
            href="#methodology"
            className="mt-8 inline-flex items-center gap-1.5 text-sm font-semibold text-indigo-600 underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:text-indigo-300 dark:focus-visible:ring-offset-slate-950"
          >
            How we measure this
            <svg
              aria-hidden="true"
              viewBox="0 0 16 16"
              className="h-4 w-4"
              fill="none"
              stroke="currentColor"
              strokeWidth={1.75}
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <path d="M3 8h10M9 4l4 4-4 4" />
            </svg>
          </a>
        </div>

        <motion.div
          variants={reduce ? undefined : listVariants}
          initial={reduce ? false : "hidden"}
          animate={started ? "show" : reduce ? undefined : "hidden"}
          className="grid grid-cols-1 gap-4 sm:grid-cols-2"
        >
          {STATS.map((stat) => (
            <motion.div
              key={stat.id}
              variants={reduce ? undefined : itemVariants}
            >
              <StatCard
                stat={stat}
                period={period}
                active={started}
                reduce={reduce}
              />
            </motion.div>
          ))}
        </motion.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 →
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

Icons Stat

Icons Stat

Original

icon stat grid

Progress Stat

Progress Stat

Original

stats with progress rings