Web InnoventixFreeCode

Comparison Stat

Original · free

before/after stat comparison

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

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

type Stat = {
  id: string;
  label: string;
  sub: string;
  before: number;
  after: number;
  word: string;
  format: (n: number) => string;
};

const STATS: Stat[] = [
  {
    id: "frt",
    label: "First reply time",
    sub: "median wait for a human response",
    before: 380,
    after: 41,
    word: "faster",
    format: (n) =>
      n >= 60 ? `${Math.floor(n / 60)}h ${Math.round(n % 60)}m` : `${Math.round(n)}m`,
  },
  {
    id: "csat",
    label: "CSAT",
    sub: "post-resolution satisfaction",
    before: 78,
    after: 94,
    word: "higher",
    format: (n) => `${n.toFixed(0)}%`,
  },
  {
    id: "auto",
    label: "Auto-resolved",
    sub: "tickets closed without an agent",
    before: 12,
    after: 61,
    word: "more",
    format: (n) => `${n.toFixed(0)}%`,
  },
  {
    id: "age",
    label: "Backlog age",
    sub: "average time a ticket sits open",
    before: 9.4,
    after: 2.1,
    word: "shorter",
    format: (n) => `${n.toFixed(1)}d`,
  },
  {
    id: "cost",
    label: "Cost per ticket",
    sub: "fully-loaded handling cost",
    before: 4.8,
    after: 1.95,
    word: "cheaper",
    format: (n) => `$${n.toFixed(2)}`,
  },
  {
    id: "repeat",
    label: "Repeat contacts",
    sub: "customers who write back twice",
    before: 31,
    after: 9,
    word: "fewer",
    format: (n) => `${n.toFixed(0)}%`,
  },
];

function pctImprovement(s: Stat): number {
  const raw = s.after >= s.before ? (s.after - s.before) / s.before : (s.before - s.after) / s.before;
  return Math.round(raw * 100);
}

export default function StatxComparison() {
  const reduce = useReducedMotion();
  const sliderId = useId();
  const [t, setT] = useState<number>(0);
  const tRef = useRef<number>(0);
  const rafRef = useRef<number | null>(null);

  const sectionRef = useRef<HTMLDivElement | null>(null);
  const inView = useInView(sectionRef, { once: true, margin: "-80px" });

  const setBoth = useCallback((v: number) => {
    tRef.current = v;
    setT(v);
  }, []);

  const stopRaf = useCallback(() => {
    if (rafRef.current !== null) {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
    }
  }, []);

  const tweenTo = useCallback(
    (target: number, dur: number) => {
      stopRaf();
      if (reduce) {
        setBoth(target);
        return;
      }
      const start = tRef.current;
      const t0 = performance.now();
      const tick = (now: number) => {
        const p = Math.min(1, (now - t0) / dur);
        const e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
        setBoth(start + (target - start) * e);
        if (p < 1) {
          rafRef.current = requestAnimationFrame(tick);
        } else {
          rafRef.current = null;
        }
      };
      rafRef.current = requestAnimationFrame(tick);
    },
    [reduce, setBoth, stopRaf],
  );

  const play = useCallback(() => {
    stopRaf();
    setBoth(0);
    if (reduce) {
      setBoth(100);
      return;
    }
    tweenTo(100, 1700);
  }, [reduce, setBoth, stopRaf, tweenTo]);

  const onScrub = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      stopRaf();
      setBoth(Number(e.target.value));
    },
    [setBoth, stopRaf],
  );

  useEffect(() => () => stopRaf(), [stopRaf]);

  const day = Math.round((t / 100) * 90);
  const stateLabel = t <= 0.5 ? "Launch day" : t >= 99.5 ? "Today · day 90" : `Day ${day}`;

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:from-neutral-950 dark:to-neutral-900 dark:text-slate-100">
      <style>{`
        @keyframes statx-sweep { 0% { background-position: 0% 50%; } 100% { background-position: 200% 50%; } }
        @keyframes statx-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.6); } }
        .statx-sweep-bar { background-size: 200% 100%; animation: statx-sweep 3.4s linear infinite; }
        .statx-live-dot { animation: statx-pulse 1.8s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .statx-sweep-bar { animation: none; }
          .statx-live-dot { animation: none; }
        }
      `}</style>

      {/* atmosphere */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 right-[-10%] h-72 w-72 rounded-full bg-emerald-400/20 blur-3xl dark:bg-emerald-500/10"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute bottom-[-15%] left-[-8%] h-80 w-80 rounded-full bg-indigo-400/15 blur-3xl dark:bg-indigo-500/10"
      />

      <div ref={sectionRef} className="relative mx-auto max-w-5xl">
        {/* header */}
        <div className="max-w-2xl">
          <div className="mb-4 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-widest text-slate-500 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-slate-400">
            90-day retro
          </div>
          <h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">
            What AI triage did to our support desk
          </h2>
          <span className="statx-sweep-bar mt-4 block h-1 w-24 rounded-full bg-gradient-to-r from-emerald-400 via-indigo-400 to-emerald-400" />
          <p className="mt-5 text-base leading-relaxed text-slate-600 dark:text-slate-300">
            Every inbound conversation now gets classified, prioritised and routed before a human
            ever sees it. Drag the timeline to watch each metric move from launch day to today.
          </p>
        </div>

        {/* control panel */}
        <div
          role="group"
          aria-label="Before and after timeline scrubber"
          className="mt-10 rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm backdrop-blur-sm sm:p-6 dark:border-white/10 dark:bg-white/[0.04]"
        >
          <div className="flex flex-wrap items-center justify-between gap-3">
            <div className="flex items-center gap-2">
              <span className="statx-live-dot h-2.5 w-2.5 rounded-full bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.15)]" />
              <span aria-live="polite" className="text-sm font-semibold tabular-nums">
                {stateLabel}
              </span>
            </div>
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => tweenTo(0, 520)}
                className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-white/10 dark:bg-white/5 dark:text-slate-200 dark:hover:bg-white/10 dark:focus-visible:ring-offset-neutral-900"
              >
                Before
              </button>
              <button
                type="button"
                onClick={() => tweenTo(100, 720)}
                className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-white/10 dark:bg-white/5 dark:text-slate-200 dark:hover:bg-white/10 dark:focus-visible:ring-offset-neutral-900"
              >
                After
              </button>
              <button
                type="button"
                onClick={play}
                className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900"
              >
                <svg viewBox="0 0 12 12" className="h-3 w-3 fill-current" aria-hidden="true">
                  <path d="M2 1.5v9l8-4.5z" />
                </svg>
                Play
              </button>
            </div>
          </div>

          <label htmlFor={sliderId} className="sr-only">
            Scrub the timeline from launch day to today
          </label>
          <div className="relative mt-5 h-6">
            <input
              id={sliderId}
              type="range"
              min={0}
              max={100}
              step={1}
              value={t}
              onChange={onScrub}
              aria-valuetext={`Day ${day} of 90`}
              className="peer absolute inset-0 z-20 m-0 w-full cursor-pointer appearance-none bg-transparent opacity-0"
            />
            <div className="pointer-events-none absolute left-0 right-0 top-1/2 z-10 h-2 -translate-y-1/2 overflow-hidden rounded-full bg-slate-200 dark:bg-white/10">
              <div
                className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600"
                style={{ width: `${t}%` }}
              />
            </div>
            <div
              className="pointer-events-none absolute top-1/2 z-10 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-emerald-500 bg-white shadow-md transition-[box-shadow] peer-focus-visible:ring-2 peer-focus-visible:ring-emerald-500/70 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:bg-neutral-900 dark:peer-focus-visible:ring-offset-neutral-900"
              style={{ left: `${t}%` }}
            />
          </div>
          <div className="mt-2 flex items-center justify-between text-xs font-medium text-slate-500 dark:text-slate-400">
            <span>Launch day</span>
            <span>Today &middot; day 90</span>
          </div>
        </div>

        {/* stat grid */}
        <ul className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {STATS.map((s, i) => {
            const current = s.before + (s.after - s.before) * (t / 100);
            const up = s.after >= s.before;
            const pct = pctImprovement(s);
            return (
              <motion.li
                key={s.id}
                initial={reduce ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
                animate={inView ? { opacity: 1, y: 0 } : undefined}
                transition={{ duration: 0.5, delay: reduce ? 0 : i * 0.06, ease: [0.22, 1, 0.36, 1] }}
                className="group rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-white/[0.03]"
              >
                <div className="flex items-start justify-between gap-3">
                  <div>
                    <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                      {s.label}
                    </h3>
                    <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{s.sub}</p>
                  </div>
                  <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-semibold text-emerald-700 ring-1 ring-inset ring-emerald-500/25 dark:text-emerald-300">
                    <svg
                      viewBox="0 0 12 12"
                      className={`h-2.5 w-2.5 fill-current ${up ? "" : "rotate-180"}`}
                      aria-hidden="true"
                    >
                      <path d="M6 2l4 6H2z" />
                    </svg>
                    {pct}%
                  </span>
                </div>

                <div className="mt-4 flex items-end justify-between gap-2">
                  <span className="text-3xl font-semibold tabular-nums tracking-tight text-slate-900 dark:text-white">
                    {s.format(current)}
                  </span>
                  <span className="pb-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
                    {pct}% {s.word}
                  </span>
                </div>

                <div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-white/10">
                  <div
                    className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600"
                    style={{ width: `${t}%` }}
                  />
                </div>

                <div className="mt-3 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
                  <span>
                    Launch{" "}
                    <span className="font-semibold tabular-nums text-slate-700 dark:text-slate-300">
                      {s.format(s.before)}
                    </span>
                  </span>
                  <svg viewBox="0 0 16 8" className="h-2 w-4 text-slate-300 dark:text-slate-600" aria-hidden="true">
                    <path d="M0 4h13M11 1l3 3-3 3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                  <span>
                    Now{" "}
                    <span className="font-semibold tabular-nums text-emerald-700 dark:text-emerald-300">
                      {s.format(s.after)}
                    </span>
                  </span>
                </div>
              </motion.li>
            );
          })}
        </ul>

        <p className="mt-6 text-xs text-slate-400 dark:text-slate-500">
          Figures aggregated across 41,280 tickets between launch and day 90. Drag, tab to the
          slider and use arrow keys, or hit Play to replay the transition.
        </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