Web InnoventixFreeCode

Cards Stat

Original · free

stat cards with icons

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

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

type RangeId = "d30" | "q" | "ytd";
type AccentKey = "indigo" | "emerald" | "violet" | "sky";
type IconKey = "users" | "revenue" | "conversion" | "latency";

interface RangePoint {
  value: number;
  delta: number;
  trend: "up" | "down";
  positive: boolean;
  spark: number[];
}

interface Metric {
  id: string;
  label: string;
  caption: string;
  accent: AccentKey;
  icon: IconKey;
  prefix?: string;
  suffix?: string;
  decimals: number;
  ranges: Record<RangeId, RangePoint>;
}

const TABS: { id: RangeId; label: string }[] = [
  { id: "d30", label: "Last 30 days" },
  { id: "q", label: "Last quarter" },
  { id: "ytd", label: "Year to date" },
];

const ACCENT: Record<AccentKey, { iconWrap: string; spark: string; glow: string }> = {
  indigo: {
    iconWrap: "bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300",
    spark: "text-indigo-500 dark:text-indigo-400",
    glow: "from-indigo-400/40",
  },
  emerald: {
    iconWrap: "bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300",
    spark: "text-emerald-500 dark:text-emerald-400",
    glow: "from-emerald-400/40",
  },
  violet: {
    iconWrap: "bg-violet-100 text-violet-600 dark:bg-violet-500/15 dark:text-violet-300",
    spark: "text-violet-500 dark:text-violet-400",
    glow: "from-violet-400/40",
  },
  sky: {
    iconWrap: "bg-sky-100 text-sky-600 dark:bg-sky-500/15 dark:text-sky-300",
    spark: "text-sky-500 dark:text-sky-400",
    glow: "from-sky-400/40",
  },
};

const METRICS: Metric[] = [
  {
    id: "active-users",
    label: "Weekly active accounts",
    caption: "Workspaces with at least one signed-in session",
    accent: "indigo",
    icon: "users",
    decimals: 0,
    ranges: {
      d30: { value: 48920, delta: 12.4, trend: "up", positive: true, spark: [32, 35, 34, 38, 41, 40, 44, 46, 49] },
      q: { value: 46210, delta: 6.8, trend: "up", positive: true, spark: [40, 42, 41, 44, 43, 45, 44, 46, 46] },
      ytd: { value: 52740, delta: 21.9, trend: "up", positive: true, spark: [30, 33, 36, 40, 43, 47, 49, 51, 53] },
    },
  },
  {
    id: "mrr",
    label: "Monthly recurring revenue",
    caption: "Net of churn, refunds and downgrades",
    accent: "emerald",
    icon: "revenue",
    prefix: "$",
    suffix: "M",
    decimals: 2,
    ranges: {
      d30: { value: 1.24, delta: 8.1, trend: "up", positive: true, spark: [0.7, 0.8, 0.85, 0.9, 1.0, 1.05, 1.1, 1.18, 1.24] },
      q: { value: 3.61, delta: 14.2, trend: "up", positive: true, spark: [2.4, 2.6, 2.7, 2.9, 3.0, 3.2, 3.3, 3.5, 3.61] },
      ytd: { value: 12.8, delta: 33.5, trend: "up", positive: true, spark: [5, 6.2, 7, 8.1, 9, 10.2, 11, 12, 12.8] },
    },
  },
  {
    id: "conversion",
    label: "Activation rate",
    caption: "Visitors who reach an activated first session",
    accent: "violet",
    icon: "conversion",
    suffix: "%",
    decimals: 1,
    ranges: {
      d30: { value: 4.8, delta: 3.2, trend: "up", positive: true, spark: [3.9, 4.0, 4.1, 4.2, 4.4, 4.5, 4.6, 4.7, 4.8] },
      q: { value: 4.3, delta: 1.6, trend: "down", positive: false, spark: [4.9, 4.8, 4.7, 4.6, 4.5, 4.5, 4.4, 4.35, 4.3] },
      ytd: { value: 5.1, delta: 9.4, trend: "up", positive: true, spark: [4.2, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9, 5.0, 5.1] },
    },
  },
  {
    id: "latency",
    label: "Median response time",
    caption: "Time to first byte at the 50th percentile",
    accent: "sky",
    icon: "latency",
    suffix: "ms",
    decimals: 0,
    ranges: {
      d30: { value: 142, delta: 9.0, trend: "down", positive: true, spark: [168, 162, 158, 155, 150, 148, 146, 144, 142] },
      q: { value: 158, delta: 11.0, trend: "up", positive: false, spark: [140, 143, 146, 148, 150, 152, 154, 156, 158] },
      ytd: { value: 131, delta: 18.0, trend: "down", positive: true, spark: [160, 155, 150, 146, 143, 140, 136, 133, 131] },
    },
  },
];

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

function useCountUp(value: number, reduce: boolean, duration = 1000): number {
  const [display, setDisplay] = useState<number>(reduce ? value : 0);
  const fromRef = useRef<number>(reduce ? value : 0);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    if (reduce) {
      fromRef.current = value;
      setDisplay(value);
      return;
    }
    const from = fromRef.current;
    const to = value;
    const start = performance.now();
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / duration);
      const eased = 1 - Math.pow(1 - t, 3);
      setDisplay(from + (to - from) * eased);
      if (t < 1) {
        rafRef.current = requestAnimationFrame(tick);
      } else {
        fromRef.current = to;
      }
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
  }, [value, reduce, duration]);

  return display;
}

function AnimatedNumber({
  value,
  decimals,
  prefix,
  suffix,
  reduce,
}: {
  value: number;
  decimals: number;
  prefix?: string;
  suffix?: string;
  reduce: boolean;
}) {
  const display = useCountUp(value, reduce);
  return (
    <span className="tabular-nums">
      {prefix}
      {formatValue(display, decimals)}
      <span className="ml-0.5 text-2xl font-medium text-slate-400 dark:text-slate-500">{suffix}</span>
    </span>
  );
}

function Sparkline({ points, className }: { points: number[]; className?: string }) {
  const w = 100;
  const h = 34;
  const max = Math.max(...points);
  const min = Math.min(...points);
  const range = max - min || 1;
  const step = w / (points.length - 1);
  const coords = points.map((p, i) => {
    const x = i * step;
    const y = h - ((p - min) / range) * (h - 6) - 3;
    return { x, y };
  });
  const line = coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(" ");
  const area =
    `M0,${h} ` +
    coords.map((c) => `L${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(" ") +
    ` L${w},${h} Z`;
  return (
    <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className={className} aria-hidden="true">
      <path d={area} className="fill-current opacity-10" />
      <polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function Icon({ name, className }: { name: IconKey; className?: string }) {
  const common = {
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.8,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    className,
    "aria-hidden": true,
  };
  switch (name) {
    case "users":
      return (
        <svg {...common}>
          <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
          <circle cx="9" cy="7" r="4" />
          <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
          <path d="M16 3.13a4 4 0 0 1 0 7.75" />
        </svg>
      );
    case "revenue":
      return (
        <svg {...common}>
          <polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
          <polyline points="17 6 23 6 23 12" />
        </svg>
      );
    case "conversion":
      return (
        <svg {...common}>
          <circle cx="12" cy="12" r="10" />
          <circle cx="12" cy="12" r="6" />
          <circle cx="12" cy="12" r="2" />
        </svg>
      );
    case "latency":
      return (
        <svg {...common}>
          <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
        </svg>
      );
  }
}

export default function StatxCards() {
  const reduce = useReducedMotion() ?? false;
  const [range, setRange] = useState<RangeId>("d30");
  const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const onKey = (e: ReactKeyboardEvent<HTMLButtonElement>, i: number) => {
    let next = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % TABS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + TABS.length) % TABS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = TABS.length - 1;
    else return;
    e.preventDefault();
    setRange(TABS[next].id);
    btnRefs.current[next]?.focus();
  };

  return (
    <section
      aria-labelledby="statx-heading"
      className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-24 dark:from-slate-950 dark:to-slate-900 sm:py-28"
    >
      <style>{`
        .statx-blob-a { animation: statx-float 12s ease-in-out infinite; }
        .statx-blob-b { animation: statx-float 15s ease-in-out infinite reverse; }
        .statx-dot { animation: statx-pulse 2.4s ease-in-out infinite; }
        @keyframes statx-float {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(0, -26px, 0); }
        }
        @keyframes statx-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.35; transform: scale(0.6); }
        }
        @media (prefers-reduced-motion: reduce) {
          .statx-blob-a, .statx-blob-b, .statx-dot { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div
        aria-hidden="true"
        className="statx-blob-a pointer-events-none absolute -left-24 top-8 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20"
      />
      <div
        aria-hidden="true"
        className="statx-blob-b pointer-events-none absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-violet-300/25 blur-3xl dark:bg-violet-700/20"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-[0.04] dark:opacity-[0.07]"
        style={{
          backgroundImage:
            "linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)",
          backgroundSize: "48px 48px",
          color: "#64748b",
        }}
      />

      <div className="relative mx-auto max-w-6xl">
        {/* header */}
        <div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
          <div className="max-w-2xl">
            <span className="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-wider text-indigo-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-indigo-300">
              <span className="statx-dot inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
              Cadence · Product analytics
            </span>
            <h2
              id="statx-heading"
              className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl"
            >
              Every metric that moves the business, in one glance.
            </h2>
            <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
              Cadence rolls up product, revenue and performance signals into one live view, so your team
              spends less time stitching spreadsheets together and more time shipping.
            </p>
          </div>

          {/* segmented range control */}
          <div
            role="radiogroup"
            aria-label="Select reporting period"
            className="inline-flex shrink-0 rounded-full border border-slate-200 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70"
          >
            {TABS.map((tab, i) => {
              const selected = tab.id === range;
              return (
                <button
                  key={tab.id}
                  ref={(el) => {
                    btnRefs.current[i] = el;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={selected}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setRange(tab.id)}
                  onKeyDown={(e) => onKey(e, i)}
                  className="relative rounded-full px-4 py-2 text-sm font-medium outline-none transition-colors 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 && (
                    <motion.span
                      layoutId="statx-range-pill"
                      className="absolute inset-0 rounded-full bg-slate-900 dark:bg-white"
                      transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
                    />
                  )}
                  <span
                    className={
                      "relative z-10 " +
                      (selected
                        ? "text-white dark:text-slate-900"
                        : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200")
                    }
                  >
                    {tab.label}
                  </span>
                </button>
              );
            })}
          </div>
        </div>

        {/* cards */}
        <div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
          {METRICS.map((m, i) => {
            const point = m.ranges[range];
            const accent = ACCENT[m.accent];
            return (
              <motion.div
                key={m.id}
                initial={{ opacity: 0, y: reduce ? 0 : 18 }}
                whileInView={{ opacity: 1, y: 0 }}
                viewport={{ once: true, amount: 0.3 }}
                transition={{ duration: 0.5, delay: reduce ? 0 : i * 0.08, ease: "easeOut" }}
                className="group relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:shadow-black/40"
              >
                <span
                  aria-hidden="true"
                  className={
                    "pointer-events-none absolute -right-10 -top-10 h-28 w-28 rounded-full bg-gradient-to-br to-transparent opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100 " +
                    accent.glow
                  }
                />

                <div className="relative flex items-start justify-between">
                  <span className={"inline-flex h-11 w-11 items-center justify-center rounded-xl " + accent.iconWrap}>
                    <Icon name={m.icon} className="h-5 w-5" />
                  </span>
                  <span
                    className={
                      "inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold " +
                      (point.positive
                        ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
                        : "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300")
                    }
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={2.4}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="h-3 w-3"
                      aria-hidden="true"
                    >
                      {point.trend === "up" ? (
                        <path d="M12 19V5M5 12l7-7 7 7" />
                      ) : (
                        <path d="M12 5v14M19 12l-7 7-7-7" />
                      )}
                    </svg>
                    {point.delta.toFixed(1)}%
                    <span className="sr-only">
                      {point.positive ? "improvement" : "decline"} versus previous period
                    </span>
                  </span>
                </div>

                <div className="relative mt-6">
                  <div className="text-4xl font-semibold tracking-tight text-slate-900 dark:text-white">
                    <AnimatedNumber
                      value={point.value}
                      decimals={m.decimals}
                      prefix={m.prefix}
                      suffix={m.suffix}
                      reduce={reduce}
                    />
                  </div>
                  <p className="mt-2 text-sm font-medium text-slate-500 dark:text-slate-400">{m.label}</p>
                </div>

                <p className="relative mt-4 text-xs leading-relaxed text-slate-400 dark:text-slate-500">
                  {m.caption}
                </p>

                <div className="relative mt-3">
                  <Sparkline points={point.spark} className={"h-9 w-full " + accent.spark} />
                </div>
              </motion.div>
            );
          })}
        </div>

        {/* footer */}
        <div className="mt-10 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-slate-400 dark:text-slate-500">
          <span className="inline-flex items-center gap-1.5">
            <span className="statx-dot inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Synced 2 minutes ago
          </span>
          <span aria-hidden="true">·</span>
          <span>Sample figures shown for demonstration</span>
        </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

Gradient Stat

Gradient Stat

Original

gradient stats band

Split Stat

Split Stat

Original

stats beside copy

Icons Stat

Icons Stat

Original

icon stat grid

Progress Stat

Progress Stat

Original

stats with progress rings