Web InnoventixFreeCode

Trend Stat

Original · free

stats with mini trend sparklines

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

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

type RangeKey = "7d" | "30d" | "90d";
type MetricId = "visitors" | "revenue" | "conversion" | "session";
type AccentKey = "indigo" | "emerald" | "violet" | "sky";

interface Accent {
  text: string;
  ring: string;
  chip: string;
  dot: string;
  hover: string;
}

interface MetricDef {
  id: MetricId;
  label: string;
  hint: string;
  accent: AccentKey;
  seed: number;
  base: number;
  trend: number;
  volatility: number;
  format: (n: number) => string;
}

interface RangeDef {
  key: RangeKey;
  label: string;
  days: number;
}

interface Point {
  x: number;
  y: number;
  v: number;
}

interface Pad {
  l: number;
  r: number;
  t: number;
  b: number;
}

const ACCENTS: Record<AccentKey, Accent> = {
  indigo: {
    text: "text-indigo-600 dark:text-indigo-400",
    ring: "ring-indigo-500/70 dark:ring-indigo-400/70",
    chip: "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300",
    dot: "bg-indigo-500",
    hover: "hover:border-indigo-300 dark:hover:border-indigo-500/60",
  },
  emerald: {
    text: "text-emerald-600 dark:text-emerald-400",
    ring: "ring-emerald-500/70 dark:ring-emerald-400/70",
    chip: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
    dot: "bg-emerald-500",
    hover: "hover:border-emerald-300 dark:hover:border-emerald-500/60",
  },
  violet: {
    text: "text-violet-600 dark:text-violet-400",
    ring: "ring-violet-500/70 dark:ring-violet-400/70",
    chip: "bg-violet-500/10 text-violet-700 dark:text-violet-300",
    dot: "bg-violet-500",
    hover: "hover:border-violet-300 dark:hover:border-violet-500/60",
  },
  sky: {
    text: "text-sky-600 dark:text-sky-400",
    ring: "ring-sky-500/70 dark:ring-sky-400/70",
    chip: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
    dot: "bg-sky-500",
    hover: "hover:border-sky-300 dark:hover:border-sky-500/60",
  },
};

const MONTHS = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

const ANCHOR = new Date(2026, 6, 16);

function dayLabel(i: number, n: number): string {
  const d = new Date(ANCHOR);
  d.setDate(d.getDate() - (n - 1 - i));
  return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}

function countFmt(n: number): string {
  return Math.round(n).toLocaleString("en-US");
}

function currencyFmt(n: number): string {
  if (n >= 1000) return `$${(n / 1000).toFixed(1)}k`;
  return `$${Math.round(n)}`;
}

function percentFmt(n: number): string {
  return `${n.toFixed(2)}%`;
}

function durationFmt(n: number): string {
  const total = Math.round(n);
  const m = Math.floor(total / 60);
  const s = total % 60;
  return `${m}m ${s.toString().padStart(2, "0")}s`;
}

const METRICS: MetricDef[] = [
  {
    id: "visitors",
    label: "Unique visitors",
    hint: "Sessions from new and returning users",
    accent: "indigo",
    seed: 1487,
    base: 11800,
    trend: 0.19,
    volatility: 0.05,
    format: countFmt,
  },
  {
    id: "revenue",
    label: "Net revenue",
    hint: "Booked revenue after refunds",
    accent: "emerald",
    seed: 9021,
    base: 38400,
    trend: 0.27,
    volatility: 0.045,
    format: currencyFmt,
  },
  {
    id: "conversion",
    label: "Checkout rate",
    hint: "Visitors who complete a purchase",
    accent: "violet",
    seed: 3355,
    base: 2.84,
    trend: 0.16,
    volatility: 0.06,
    format: percentFmt,
  },
  {
    id: "session",
    label: "Avg. session",
    hint: "Median engaged time on site",
    accent: "sky",
    seed: 7712,
    base: 274,
    trend: -0.09,
    volatility: 0.05,
    format: durationFmt,
  },
];

const RANGES: RangeDef[] = [
  { key: "7d", label: "7 days", days: 7 },
  { key: "30d", label: "30 days", days: 30 },
  { key: "90d", label: "90 days", days: 90 },
];

function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function makeSeries(m: MetricDef, length: number): number[] {
  const rnd = mulberry32(m.seed + length * 7);
  const out: number[] = [];
  let v = m.base;
  for (let i = 0; i < length; i += 1) {
    const drift = 1 + m.trend / length;
    const noise = (rnd() - 0.5) * m.volatility;
    v = v * drift * (1 + noise);
    out.push(Math.max(v, m.base * 0.15));
  }
  return out;
}

function toPoints(values: number[], w: number, h: number, pad: Pad): Point[] {
  const min = Math.min(...values);
  const max = Math.max(...values);
  const span = max - min || 1;
  const iw = w - pad.l - pad.r;
  const ih = h - pad.t - pad.b;
  const n = values.length;
  return values.map((v, i) => ({
    x: pad.l + (n === 1 ? 0 : (i / (n - 1)) * iw),
    y: pad.t + (1 - (v - min) / span) * ih,
    v,
  }));
}

function linePath(points: Point[]): string {
  return points
    .map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
    .join(" ");
}

function areaPath(points: Point[], baseline: number): string {
  if (points.length === 0) return "";
  const first = points[0];
  const last = points[points.length - 1];
  return `${linePath(points)} L${last.x.toFixed(2)} ${baseline.toFixed(
    2,
  )} L${first.x.toFixed(2)} ${baseline.toFixed(2)} Z`;
}

function TrendArrow({ up }: { up: boolean }) {
  return (
    <svg
      viewBox="0 0 12 12"
      className="h-3 w-3"
      aria-hidden="true"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {up ? (
        <path d="M3 8.5 6.2 4.2 8.4 6.6 10.5 3.2M10.5 3.2H7.9M10.5 3.2V5.8" />
      ) : (
        <path d="M3 3.5 6.2 7.8 8.4 5.4 10.5 8.8M10.5 8.8H7.9M10.5 8.8V6.2" />
      )}
    </svg>
  );
}

function DeltaBadge({ delta }: { delta: number }) {
  const up = delta >= 0;
  const tone = up
    ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
    : "bg-rose-500/10 text-rose-700 dark:text-rose-300";
  return (
    <span
      className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ${tone}`}
    >
      <TrendArrow up={up} />
      {up ? "+" : "−"}
      {Math.abs(delta).toFixed(1)}%
    </span>
  );
}

function Sparkline({
  values,
  accent,
}: {
  values: number[];
  accent: Accent;
}) {
  const gid = useId().replace(/:/g, "");
  const W = 128;
  const H = 40;
  const pad: Pad = { l: 2, r: 2, t: 6, b: 6 };
  const points = toPoints(values, W, H, pad);
  const last = points[points.length - 1];
  return (
    <svg
      viewBox={`0 0 ${W} ${H}`}
      className={`h-10 w-full overflow-visible ${accent.text}`}
      preserveAspectRatio="none"
      aria-hidden="true"
    >
      <defs>
        <linearGradient id={`spark-${gid}`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="currentColor" stopOpacity={0.28} />
          <stop offset="100%" stopColor="currentColor" stopOpacity={0} />
        </linearGradient>
      </defs>
      <path d={areaPath(points, H - pad.b)} fill={`url(#spark-${gid})`} />
      <path
        d={linePath(points)}
        fill="none"
        stroke="currentColor"
        strokeWidth={1.75}
        strokeLinecap="round"
        strokeLinejoin="round"
        vectorEffect="non-scaling-stroke"
      />
      {last ? (
        <circle
          cx={last.x}
          cy={last.y}
          r={2.4}
          fill="currentColor"
          vectorEffect="non-scaling-stroke"
        />
      ) : null}
    </svg>
  );
}

const VIEW_W = 760;
const VIEW_H = 260;
const FPAD: Pad = { l: 16, r: 16, t: 22, b: 34 };
const PLOT_W = VIEW_W - FPAD.l - FPAD.r;
const BASELINE_Y = VIEW_H - FPAD.b;

export default function StatxTrend() {
  const reduce = useReducedMotion();

  const [range, setRange] = useState<RangeKey>("30d");
  const [selectedMetric, setSelectedMetric] = useState<MetricId>("revenue");
  const [cursor, setCursor] = useState<number>(0);

  const rangeRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const cardRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const featuredSvgRef = useRef<SVGSVGElement | null>(null);

  const rangeDays = useMemo(
    () => RANGES.find((r) => r.key === range)?.days ?? 30,
    [range],
  );

  const seriesByMetric = useMemo<Record<MetricId, number[]>>(() => {
    const entries = METRICS.map(
      (m) => [m.id, makeSeries(m, rangeDays)] as const,
    );
    return {
      visitors: entries[0][1],
      revenue: entries[1][1],
      conversion: entries[2][1],
      session: entries[3][1],
    };
  }, [rangeDays]);

  const activeMetric = useMemo(
    () => METRICS.find((m) => m.id === selectedMetric) ?? METRICS[0],
    [selectedMetric],
  );

  const featuredValues = seriesByMetric[selectedMetric];
  const n = featuredValues.length;

  const featuredPoints = useMemo(
    () => toPoints(featuredValues, VIEW_W, VIEW_H, FPAD),
    [featuredValues],
  );

  useEffect(() => {
    setCursor(n - 1);
  }, [range, selectedMetric, n]);

  const clamped = Math.max(0, Math.min(n - 1, cursor));
  const activeAccent = ACCENTS[activeMetric.accent];
  const cursorPoint = featuredPoints[clamped];
  const cursorValue = featuredValues[clamped];
  const cursorDate = dayLabel(clamped, n);

  const featuredDelta = useMemo(() => {
    const first = featuredValues[0];
    const last = featuredValues[n - 1];
    if (!first) return 0;
    return ((last - first) / first) * 100;
  }, [featuredValues, n]);

  const peak = Math.max(...featuredValues);
  const trough = Math.min(...featuredValues);
  const average =
    featuredValues.reduce((sum, v) => sum + v, 0) / (n || 1);

  const scrubFromClientX = (clientX: number) => {
    const svg = featuredSvgRef.current;
    if (!svg) return;
    const rect = svg.getBoundingClientRect();
    if (rect.width === 0) return;
    const vx = ((clientX - rect.left) / rect.width) * VIEW_W;
    const ratio = (vx - FPAD.l) / PLOT_W;
    const idx = Math.round(ratio * (n - 1));
    setCursor(Math.max(0, Math.min(n - 1, idx)));
  };

  const onPlotPointer = (e: ReactPointerEvent<SVGRectElement>) => {
    scrubFromClientX(e.clientX);
  };

  const onSliderKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    let next = clamped;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = Math.min(n - 1, clamped + 1);
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = Math.max(0, clamped - 1);
        break;
      case "PageUp":
        next = Math.min(n - 1, clamped + 7);
        break;
      case "PageDown":
        next = Math.max(0, clamped - 7);
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = n - 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    setCursor(next);
  };

  const onRangeKey = (
    e: ReactKeyboardEvent<HTMLButtonElement>,
    idx: number,
  ) => {
    const len = RANGES.length;
    let ni = idx;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        ni = (idx + 1) % len;
        break;
      case "ArrowLeft":
      case "ArrowUp":
        ni = (idx - 1 + len) % len;
        break;
      case "Home":
        ni = 0;
        break;
      case "End":
        ni = len - 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    setRange(RANGES[ni].key);
    rangeRefs.current[ni]?.focus();
  };

  const onCardKey = (
    e: ReactKeyboardEvent<HTMLButtonElement>,
    idx: number,
  ) => {
    const len = METRICS.length;
    let ni = idx;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        ni = (idx + 1) % len;
        break;
      case "ArrowLeft":
      case "ArrowUp":
        ni = (idx - 1 + len) % len;
        break;
      case "Home":
        ni = 0;
        break;
      case "End":
        ni = len - 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    setSelectedMetric(METRICS[ni].id);
    cardRefs.current[ni]?.focus();
  };

  const tickIdx = [0, Math.floor((n - 1) / 2), n - 1];
  const gradId = `feat-${activeMetric.id}`;
  const drawKey = `${selectedMetric}-${range}`;

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 lg:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes statx-pulse {
          0%, 100% { opacity: 0.45; transform: scale(1); }
          50% { opacity: 1; transform: scale(1.6); }
        }
        .statx-pulse-dot { animation: statx-pulse 2.4s ease-in-out infinite; transform-origin: center; }
        @media (prefers-reduced-motion: reduce) {
          .statx-pulse-dot { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.12),transparent)]"
      />

      <div className="mx-auto max-w-5xl">
        <header className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-xl">
            <div className="mb-3 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
              <span className="relative flex h-2 w-2">
                <span className="statx-pulse-dot absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
                <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
              </span>
              Live workspace · synced 2 min ago
            </div>
            <h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">
              Growth pulse
            </h2>
            <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              Four signals from the storefront, each with its own trend line.
              Pick a metric to inspect it, then scrub the timeline to read any
              day.
            </p>
          </div>

          <div
            role="radiogroup"
            aria-label="Select time range"
            className="inline-flex shrink-0 rounded-xl border border-slate-200 bg-white p-1 shadow-sm dark:border-slate-800 dark:bg-slate-900"
          >
            {RANGES.map((r, idx) => {
              const on = r.key === range;
              return (
                <button
                  key={r.key}
                  ref={(el) => {
                    rangeRefs.current[idx] = el;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={on}
                  tabIndex={on ? 0 : -1}
                  onClick={() => setRange(r.key)}
                  onKeyDown={(e) => onRangeKey(e, idx)}
                  className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${focusRing} focus-visible:ring-indigo-500 ${
                    on
                      ? "bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-900"
                      : "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
                  }`}
                >
                  {r.label}
                </button>
              );
            })}
          </div>
        </header>

        <div className="mt-8 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex flex-col gap-4 border-b border-slate-100 p-6 sm:flex-row sm:items-start sm:justify-between dark:border-slate-800">
            <div>
              <div className="flex items-center gap-2">
                <span className={`h-2.5 w-2.5 rounded-full ${activeAccent.dot}`} />
                <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
                  {activeMetric.label}
                </span>
              </div>
              <div className="mt-2 flex items-baseline gap-3">
                <span className="text-3xl font-semibold tabular-nums tracking-tight sm:text-4xl">
                  {activeMetric.format(cursorValue)}
                </span>
                <DeltaBadge delta={featuredDelta} />
              </div>
              <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                {cursorDate} · {activeMetric.hint}
              </p>
            </div>
            <dl className="grid grid-cols-3 gap-4 text-right sm:gap-6">
              {[
                { k: "Peak", v: activeMetric.format(peak) },
                { k: "Average", v: activeMetric.format(average) },
                { k: "Low", v: activeMetric.format(trough) },
              ].map((s) => (
                <div key={s.k}>
                  <dt className="text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
                    {s.k}
                  </dt>
                  <dd className="mt-1 text-sm font-semibold tabular-nums text-slate-700 dark:text-slate-200">
                    {s.v}
                  </dd>
                </div>
              ))}
            </dl>
          </div>

          <div className="p-4 sm:p-6">
            <div
              role="slider"
              tabIndex={0}
              aria-label={`Scrub ${activeMetric.label} timeline`}
              aria-valuemin={0}
              aria-valuemax={n - 1}
              aria-valuenow={clamped}
              aria-valuetext={`${cursorDate}: ${activeMetric.format(
                cursorValue,
              )}`}
              onKeyDown={onSliderKey}
              className={`group relative block w-full rounded-xl ${focusRing} focus-visible:ring-indigo-500`}
            >
              <svg
                ref={featuredSvgRef}
                viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
                className={`h-56 w-full touch-none ${activeAccent.text}`}
                role="img"
                aria-label={`${activeMetric.label} over the last ${rangeDays} days`}
              >
                <defs>
                  <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
                    <stop
                      offset="0%"
                      stopColor="currentColor"
                      stopOpacity={0.24}
                    />
                    <stop
                      offset="100%"
                      stopColor="currentColor"
                      stopOpacity={0}
                    />
                  </linearGradient>
                </defs>

                {[0, 1, 2, 3, 4].map((g) => {
                  const y = FPAD.t + (g / 4) * (VIEW_H - FPAD.t - FPAD.b);
                  return (
                    <line
                      key={g}
                      x1={FPAD.l}
                      x2={VIEW_W - FPAD.r}
                      y1={y}
                      y2={y}
                      className="stroke-slate-100 dark:stroke-slate-800"
                      strokeWidth={1}
                    />
                  );
                })}

                <motion.path
                  key={`area-${drawKey}`}
                  d={areaPath(featuredPoints, BASELINE_Y)}
                  fill={`url(#${gradId})`}
                  initial={{ opacity: reduce ? 1 : 0 }}
                  animate={{ opacity: 1 }}
                  transition={{ duration: reduce ? 0 : 0.6, ease: "easeOut" }}
                />
                <motion.path
                  key={`line-${drawKey}`}
                  d={linePath(featuredPoints)}
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={2.25}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  initial={{ pathLength: reduce ? 1 : 0 }}
                  animate={{ pathLength: 1 }}
                  transition={{
                    duration: reduce ? 0 : 0.9,
                    ease: "easeInOut",
                  }}
                />

                {cursorPoint ? (
                  <g>
                    <line
                      x1={cursorPoint.x}
                      x2={cursorPoint.x}
                      y1={FPAD.t}
                      y2={BASELINE_Y}
                      className="stroke-slate-300 dark:stroke-slate-600"
                      strokeWidth={1}
                      strokeDasharray="3 3"
                    />
                    <circle
                      cx={cursorPoint.x}
                      cy={cursorPoint.y}
                      r={6}
                      fill="currentColor"
                      opacity={0.18}
                    />
                    <circle
                      cx={cursorPoint.x}
                      cy={cursorPoint.y}
                      r={3.5}
                      fill="currentColor"
                      className="stroke-white dark:stroke-slate-900"
                      strokeWidth={2}
                    />
                  </g>
                ) : null}

                {tickIdx.map((ti, k) => {
                  const p = featuredPoints[ti];
                  if (!p) return null;
                  const anchor =
                    k === 0 ? "start" : k === tickIdx.length - 1 ? "end" : "middle";
                  return (
                    <text
                      key={ti}
                      x={p.x}
                      y={VIEW_H - 12}
                      textAnchor={anchor}
                      className="fill-slate-400 text-[11px] dark:fill-slate-500"
                    >
                      {dayLabel(ti, n)}
                    </text>
                  );
                })}

                <rect
                  x={FPAD.l}
                  y={FPAD.t}
                  width={PLOT_W}
                  height={VIEW_H - FPAD.t - FPAD.b}
                  fill="transparent"
                  onPointerDown={onPlotPointer}
                  onPointerMove={onPlotPointer}
                  style={{ cursor: "ew-resize" }}
                />
              </svg>
            </div>
            <p className="mt-3 text-center text-xs text-slate-400 dark:text-slate-500">
              Drag across the chart or use arrow keys to inspect a day
            </p>
          </div>
        </div>

        <div
          role="radiogroup"
          aria-label="Choose a metric to feature"
          className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"
        >
          {METRICS.map((m, idx) => {
            const values = seriesByMetric[m.id];
            const first = values[0];
            const last = values[values.length - 1];
            const delta = first ? ((last - first) / first) * 100 : 0;
            const accent = ACCENTS[m.accent];
            const on = m.id === selectedMetric;
            return (
              <motion.button
                key={m.id}
                ref={(el) => {
                  cardRefs.current[idx] = el;
                }}
                type="button"
                role="radio"
                aria-checked={on}
                tabIndex={on ? 0 : -1}
                onClick={() => setSelectedMetric(m.id)}
                onKeyDown={(e) => onCardKey(e, idx)}
                initial={reduce ? false : { opacity: 0, y: 12 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{
                  duration: reduce ? 0 : 0.4,
                  delay: reduce ? 0 : idx * 0.06,
                  ease: "easeOut",
                }}
                className={`flex flex-col rounded-2xl border bg-white p-5 text-left shadow-sm transition-colors ${focusRing} focus-visible:ring-indigo-500 dark:bg-slate-900 ${
                  on
                    ? `border-transparent ring-2 ${accent.ring}`
                    : `border-slate-200 dark:border-slate-800 ${accent.hover}`
                }`}
              >
                <div className="flex items-center justify-between">
                  <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
                    {m.label}
                  </span>
                  <span
                    className={`rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${accent.chip}`}
                  >
                    {range}
                  </span>
                </div>
                <div className="mt-2 flex items-baseline justify-between gap-2">
                  <span className="text-2xl font-semibold tabular-nums tracking-tight">
                    {m.format(last)}
                  </span>
                  <DeltaBadge delta={delta} />
                </div>
                <div className="mt-4">
                  <Sparkline values={values} accent={accent} />
                </div>
              </motion.button>
            );
          })}
        </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

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