Comparison Stat
Original · freebefore/after stat comparison
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"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 · 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
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 quoteSimilar components
Browse all →
Four Stat Metric Band
OriginalA 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
OriginalThree centred stats separated by responsive dividers, each pairing a headline figure with an uppercase label and supporting detail.

Trusted By Logo Cloud
OriginalA 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
OriginalA two-column layout pairing one oversized hero statistic with a supporting paragraph and a checklist card explaining the number.

Count-up stats reveal
OriginalA 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
OriginalFour 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
OriginalA 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
Originalanimated counter stats row

Cards Stat
Originalstat cards with icons

Gradient Stat
Originalgradient stats band

Split Stat
Originalstats beside copy
Icons Stat
Originalicon stat grid

