Fazekit

Code

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

export interface ProgressRing {
  label: string;
  /** 0–100 */
  value: number;
  /** Stroke colour (any CSS colour). Defaults to the theme primary. */
  color?: string;
}

export interface ProgressBar {
  label: string;
  /** 0–100 */
  value: number;
  /** Text shown on the right; defaults to `${value}%`. */
  display?: string;
  /** Optional 0–100 "before" value drawn as a tick on the bar. */
  before?: number;
}

export interface StatsProgressProps {
  eyebrow?: string;
  title?: string;
  description?: string;
  rings?: ProgressRing[];
  barsTitle?: string;
  bars?: ProgressBar[];
  testimonial?: { quote: string; name: string; role: string; initials?: string };
  className?: string;
}

const RINGS: ProgressRing[] = [
  { label: "Faster onboarding", value: 72, color: "var(--primary)" },
  { label: "Fewer tickets", value: 48, color: "#d946ef" },
  { label: "Renewal rate", value: 94, color: "#10b981" },
];

const BARS: ProgressBar[] = [
  { label: "Deploys automated", value: 92, before: 34 },
  { label: "Incidents auto-resolved", value: 67, before: 18 },
  { label: "Reviews under 1 hour", value: 81, before: 40 },
  { label: "Docs generated from code", value: 54, before: 9 },
];

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

export function StatsProgress({
  eyebrow = "Impact report 2026",
  title = "The results speak in percentages",
  description = "We surveyed 1,200 teams six months after switching. Here is what changed.",
  rings = RINGS,
  barsTitle = "Share of work now handled automatically",
  bars = BARS,
  testimonial = {
    quote: "We cut our release cycle from two weeks to two days. The numbers on this page are the boring part — the calm is the real win.",
    name: "Imani Brooks",
    role: "VP Engineering, Northwind",
    initials: "IB",
  },
  className,
}: StatsProgressProps) {
  const reduce = useReducedMotion();
  return (
    <section className={cn("relative w-full overflow-x-clip bg-background py-16 sm:py-20", className)}>
      <div className="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="max-w-2xl"
        >
          <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">{title}</h2>
          <p className="mt-3 text-pretty text-muted-foreground">{description}</p>
        </motion.div>

        <div className="mt-10 grid gap-4 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]">
          <div className="flex flex-col gap-4">
            <div className="grid grid-cols-3 gap-2 rounded-3xl border bg-card p-4 shadow-sm sm:gap-4 sm:p-7">
              {rings.map((r, i) => (
                <Ring key={r.label} ring={r} delay={0.1 + i * 0.15} />
              ))}
            </div>
            <motion.figure
              initial={{ opacity: 0, y: reduce ? 0 : 14 }}
              whileInView={{ opacity: 1, y: 0 }}
              viewport={{ once: true, amount: 0.4 }}
              transition={{ duration: 0.6, delay: 0.3, ease: EASE }}
              className="relative flex-1 overflow-hidden rounded-3xl border bg-card p-6 shadow-sm sm:p-7"
            >
              <Quote aria-hidden className="absolute right-5 top-5 size-10 text-primary/15" fill="currentColor" strokeWidth={0} />
              <blockquote className="relative pr-10 text-pretty text-base leading-relaxed text-card-foreground sm:text-lg">
                &ldquo;{testimonial.quote}&rdquo;
              </blockquote>
              <figcaption className="mt-5 flex items-center gap-3">
                <span className="flex size-10 items-center justify-center rounded-full bg-gradient-to-br from-primary to-fuchsia-500 text-sm font-semibold text-white">
                  {testimonial.initials ?? testimonial.name.split(" ").map((w) => w[0]).join("").slice(0, 2)}
                </span>
                <span>
                  <span className="block text-sm font-semibold text-card-foreground">{testimonial.name}</span>
                  <span className="block text-xs text-muted-foreground">{testimonial.role}</span>
                </span>
              </figcaption>
            </motion.figure>
          </div>

          <div className="rounded-3xl border bg-card p-6 shadow-sm sm:p-7">
            <h3 className="text-sm font-semibold text-card-foreground">{barsTitle}</h3>
            <ul className="mt-6 space-y-6">
              {bars.map((b, i) => (
                <Bar key={b.label} bar={b} delay={0.15 + i * 0.12} />
              ))}
            </ul>
            <div className="mt-8 flex items-center justify-between border-t pt-4 text-xs text-muted-foreground">
              <span className="flex items-center gap-1.5">
                <span className="h-2.5 w-[3px] rounded-full bg-foreground/70" /> Before switching
              </span>
              <span className="flex items-center gap-1.5">
                <span className="h-1.5 w-4 rounded-full bg-gradient-to-r from-primary to-fuchsia-500" /> After 6 months
              </span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

function useCountUp(target: number, play: boolean, delay: number, duration = 1.6) {
  const reduce = useReducedMotion();
  const mv = useMotionValue(0);
  React.useEffect(() => {
    if (!play) return;
    if (reduce) {
      mv.set(target);
      return;
    }
    const c = animate(mv, target, { duration, delay, ease: [0.16, 1, 0.3, 1] });
    return () => c.stop();
  }, [play, target, delay, duration, reduce, mv]);
  return mv;
}

function Ring({ ring, delay }: { ring: ProgressRing; delay: number }) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.6 });
  const mv = useCountUp(ring.value, inView, delay);
  const pct = useTransform(mv, (v) => Math.round(v));
  const length = useTransform(mv, (v) => Math.max(0.001, v / 100));
  const color = ring.color ?? "var(--primary)";
  return (
    <div ref={ref} className="flex min-w-0 flex-col items-center text-center">
      <div className="relative aspect-square w-full max-w-[140px]">
        <svg viewBox="0 0 100 100" className="size-full -rotate-90" aria-hidden>
          <circle cx="50" cy="50" r="42" fill="none" stroke="var(--muted)" strokeWidth="8" />
          <motion.circle
            cx="50"
            cy="50"
            r="42"
            fill="none"
            stroke={color}
            strokeWidth="8"
            strokeLinecap="round"
            style={{ pathLength: length }}
          />
        </svg>
        <div className="absolute inset-0 flex items-center justify-center">
          <span className="text-xl font-semibold tabular-nums tracking-tight text-card-foreground sm:text-3xl">
            <motion.span>{pct}</motion.span>
            <span className="text-sm text-muted-foreground sm:text-base">%</span>
          </span>
        </div>
      </div>
      <p className="mt-3 text-xs font-medium text-muted-foreground sm:text-sm">{ring.label}</p>
      <span className="sr-only">
        {ring.label}: {ring.value}%
      </span>
    </div>
  );
}

function Bar({ bar, delay }: { bar: ProgressBar; delay: number }) {
  const ref = React.useRef<HTMLLIElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.8 });
  const mv = useCountUp(bar.value, inView, delay, 1.4);
  const text = useTransform(mv, (v) => `${Math.round(v)}%`);
  const width = useTransform(mv, (v) => `${v}%`);
  return (
    <li ref={ref}>
      <div className="flex items-baseline justify-between gap-4 text-sm">
        <span className="truncate text-card-foreground">{bar.label}</span>
        <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
          {bar.display ?? <motion.span>{text}</motion.span>}
        </span>
      </div>
      <div
        className="relative mt-2 h-2.5 overflow-hidden rounded-full bg-muted"
        role="progressbar"
        aria-label={bar.label}
        aria-valuenow={bar.value}
        aria-valuemin={0}
        aria-valuemax={100}
      >
        <motion.span
          className="absolute inset-y-0 left-0 overflow-hidden rounded-full bg-gradient-to-r from-primary to-fuchsia-500"
          style={{ width }}
        >
          <motion.span
            aria-hidden
            className="absolute inset-y-0 w-10 bg-gradient-to-r from-transparent via-white/40 to-transparent"
            initial={{ left: "-20%" }}
            animate={inView ? { left: "120%" } : undefined}
            transition={{ delay: delay + 1.2, duration: 1.1, ease: "easeInOut" }}
          />
        </motion.span>
        {bar.before != null && (
          <span
            aria-hidden
            className="absolute inset-y-0 w-[3px] -translate-x-1/2 rounded-full bg-foreground/70 ring-2 ring-card"
            style={{ left: `${bar.before}%` }}
          />
        )}
      </div>
    </li>
  );
}

More in Stats

View all →