Fazekit

Code

"use client";
import * as React from "react";
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import { TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";

export interface StatMetric {
  label: string;
  value: number;
  decimals?: number;
  prefix?: string;
  suffix?: string;
  /** e.g. "+18%" — a leading "-" or "−" renders the delta as negative. */
  delta?: string;
  /** Points for the sparkline (any scale). */
  trend?: number[];
  /** Hint under the number. */
  note?: string;
}

export interface StatsCountupProps {
  eyebrow?: string;
  title?: React.ReactNode;
  caption?: string;
  footnote?: string;
  metrics?: StatMetric[];
  /** Count-up duration in seconds. */
  duration?: number;
  className?: string;
}

const METRICS: StatMetric[] = [
  {
    label: "Payments processed",
    value: 2.4,
    decimals: 1,
    prefix: "$",
    suffix: "B",
    delta: "+38%",
    note: "in the last 12 months",
    trend: [8, 10, 9, 13, 12, 15, 17, 16, 20, 23, 22, 27],
  },
  {
    label: "Uptime",
    value: 99.99,
    decimals: 2,
    suffix: "%",
    delta: "+0.02%",
    note: "rolling 90-day SLA",
    trend: [96, 97, 96.5, 98, 97.5, 98.6, 99, 98.8, 99.4, 99.6, 99.9, 99.99],
  },
  {
    label: "Active teams",
    value: 18400,
    suffix: "+",
    delta: "+2.1k",
    note: "across 140 countries",
    trend: [4, 5, 7, 6, 8, 10, 11, 13, 12, 15, 16, 18],
  },
  {
    label: "Median response",
    value: 38,
    suffix: "ms",
    delta: "−12ms",
    note: "p50 API latency",
    trend: [70, 66, 64, 60, 58, 55, 50, 49, 45, 43, 40, 38],
  },
];

const EASE = [0.22, 1, 0.36, 1] as const;

export function StatsCountup({
  eyebrow = "By the numbers",
  title = (
    <>
      Infrastructure trusted with{" "}
      <span className="bg-gradient-to-r from-primary to-fuchsia-500 bg-clip-text text-transparent">real money</span>.
    </>
  ),
  caption = "From two-person startups to public companies, teams run their most critical flows on us — and the numbers keep climbing.",
  footnote = "Figures as of September 2026. Updated monthly.",
  metrics = METRICS,
  duration = 2,
  className,
}: StatsCountupProps) {
  const reduce = useReducedMotion();
  return (
    <section className={cn("relative w-full overflow-x-clip bg-background py-16 sm:py-24", className)}>
      <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-full">
        <div className="absolute left-1/2 top-0 h-64 w-[min(900px,100%)] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
      </div>
      <div className="relative mx-auto max-w-6xl px-4 sm:px-6">
        <motion.div
          initial={{ opacity: 0, y: reduce ? 0 : 14 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, amount: 0.5 }}
          transition={{ duration: 0.6, ease: EASE }}
          className="grid gap-4 lg:grid-cols-2 lg:items-end lg:gap-16"
        >
          <div>
            <p className="text-sm font-medium text-primary">{eyebrow}</p>
            <h2 className="mt-2 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl lg:text-5xl lg:leading-[1.05]">
              {title}
            </h2>
          </div>
          <p className="max-w-md text-pretty text-muted-foreground lg:justify-self-end">{caption}</p>
        </motion.div>

        <div className="mt-12 grid gap-px overflow-hidden rounded-3xl border bg-border shadow-sm sm:grid-cols-2 lg:grid-cols-4">
          {metrics.map((m, i) => (
            <Metric key={m.label} metric={m} index={i} duration={duration} />
          ))}
        </div>
        {footnote && <p className="mt-4 text-center text-xs text-muted-foreground lg:text-right">{footnote}</p>}
      </div>
    </section>
  );
}

function Metric({ metric, index, duration }: { metric: StatMetric; index: number; duration: number }) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.5 });
  const reduce = useReducedMotion();
  const mv = useMotionValue(0);
  const decimals = metric.decimals ?? 0;
  const fmt = React.useMemo(
    () => new Intl.NumberFormat("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }),
    [decimals],
  );
  const text = useTransform(mv, (v) => fmt.format(v));
  const delay = 0.15 + index * 0.12;

  React.useEffect(() => {
    if (!inView) return;
    if (reduce) {
      mv.set(metric.value);
      return;
    }
    const c = animate(mv, metric.value, { duration, delay, ease: [0.16, 1, 0.3, 1] });
    return () => c.stop();
  }, [inView, metric.value, duration, delay, reduce, mv]);

  const negative = /^[-−]/.test(metric.delta ?? "");
  // "Good" direction: a falling latency is positive news, so colour by the trend's direction, not the sign.
  const trend = metric.trend ?? [];
  const improving = trend.length > 1 ? (negative ? trend[trend.length - 1] < trend[0] : true) : !negative;

  return (
    <div ref={ref} className="relative bg-card">
      <motion.div
        initial={{ opacity: 0, y: reduce ? 0 : 16 }}
        animate={inView ? { opacity: 1, y: 0 } : undefined}
        transition={{ duration: 0.6, delay: index * 0.08, ease: EASE }}
        className="flex h-full flex-col p-6 sm:p-7"
      >
        <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{metric.label}</p>
        <p className="mt-3 flex items-baseline text-4xl font-semibold tracking-tight text-card-foreground tabular-nums sm:text-[2.75rem]">
          {metric.prefix}
          <motion.span>{text}</motion.span>
          {metric.suffix && <span className="ml-0.5 text-2xl text-muted-foreground sm:text-3xl">{metric.suffix}</span>}
        </p>
        <div className="mt-2 flex items-center gap-2 text-xs">
          {metric.delta && (
            <span
              className={cn(
                "inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 font-medium",
                improving ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-rose-500/10 text-rose-600 dark:text-rose-400",
              )}
            >
              {negative ? <TrendingDown className="size-3" aria-hidden /> : <TrendingUp className="size-3" aria-hidden />}
              {metric.delta}
            </span>
          )}
          {metric.note && <span className="truncate text-muted-foreground">{metric.note}</span>}
        </div>
        {trend.length > 1 && <Sparkline points={trend} play={inView} delay={delay + 0.2} />}
      </motion.div>
    </div>
  );
}

function Sparkline({ points, play, delay }: { points: number[]; play: boolean; delay: number }) {
  const id = `spark-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
  const w = 200;
  const h = 48;
  const min = Math.min(...points);
  const max = Math.max(...points);
  const span = max - min || 1;
  const xy = points.map((p, i) => [(i / (points.length - 1)) * w, 4 + (1 - (p - min) / span) * (h - 8)] as const);
  const line = xy.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" ");
  const area = `${line} L${w} ${h} L0 ${h} Z`;
  const [lx, ly] = xy[xy.length - 1];
  return (
    <div className="relative mt-6 h-12 w-full" aria-hidden>
      <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible">
        <defs>
          <linearGradient id={`${id}-fill`} x1="0" x2="0" y1="0" y2="1">
            <stop offset="0%" stopColor="var(--primary)" stopOpacity="0.25" />
            <stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
          </linearGradient>
          <clipPath id={id}>
            <motion.rect
              x="-4"
              y="-8"
              height={h + 16}
              initial={{ width: 0 }}
              animate={{ width: play ? w + 8 : 0 }}
              transition={{ duration: 1.3, delay, ease: "easeInOut" }}
            />
          </clipPath>
        </defs>
        <g clipPath={`url(#${id})`}>
          <path d={area} fill={`url(#${id}-fill)`} />
          <path
            d={line}
            fill="none"
            stroke="var(--primary)"
            strokeWidth="2"
            strokeLinejoin="round"
            strokeLinecap="round"
            vectorEffect="non-scaling-stroke"
          />
        </g>
      </svg>
      <motion.span
        className="absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary ring-4 ring-primary/20"
        style={{ left: `${(lx / w) * 100}%`, top: `${(ly / h) * 100}%` }}
        initial={{ scale: 0 }}
        animate={{ scale: play ? 1 : 0 }}
        transition={{ delay: delay + 1.25, type: "spring", stiffness: 500, damping: 20 }}
      />
    </div>
  );
}

More in Stats

View all →