Web InnoventixFreeCode

Stat Card

Original · free

A metric card with value, trend arrow and a small sparkline.

byWeb InnoventixReact + Tailwind
cardstatcards
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/card-stat.json
card-stat.tsx
"use client";

import { useId, useMemo, useState } from "react";
import { useReducedMotion } from "motion/react";

type Timeframe = "7D" | "30D" | "90D";

type Point = {
  value: string;
  delta: number;
  spark: number[];
};

type Metric = {
  id: string;
  label: string;
  hint: string;
  positiveIsGood: boolean;
  data: Record<Timeframe, Point>;
};

const TIMEFRAMES: Timeframe[] = ["7D", "30D", "90D"];

const METRICS: Metric[] = [
  {
    id: "mrr",
    label: "Monthly Recurring Revenue",
    hint: "Stripe · net of refunds",
    positiveIsGood: true,
    data: {
      "7D": { value: "$48,250", delta: 4.2, spark: [41, 42, 40, 44, 45, 47, 48.25] },
      "30D": { value: "$48,250", delta: 12.4, spark: [39, 40, 42, 41, 44, 46, 48.25] },
      "90D": { value: "$48,250", delta: 31.8, spark: [30, 33, 32, 37, 39, 43, 48.25] },
    },
  },
  {
    id: "users",
    label: "Active Users",
    hint: "7-day rolling",
    positiveIsGood: true,
    data: {
      "7D": { value: "12,840", delta: 2.1, spark: [12.1, 12.3, 12.2, 12.5, 12.6, 12.7, 12.84] },
      "30D": { value: "12,840", delta: 8.1, spark: [11.6, 11.9, 12.0, 12.2, 12.4, 12.6, 12.84] },
      "90D": { value: "12,840", delta: 19.5, spark: [10.4, 10.9, 11.3, 11.7, 12.1, 12.5, 12.84] },
    },
  },
  {
    id: "churn",
    label: "Monthly Churn",
    hint: "logo churn",
    positiveIsGood: false,
    data: {
      "7D": { value: "2.3%", delta: -0.2, spark: [2.6, 2.5, 2.6, 2.4, 2.5, 2.4, 2.3] },
      "30D": { value: "2.3%", delta: -0.6, spark: [3.1, 2.9, 2.8, 2.7, 2.6, 2.4, 2.3] },
      "90D": { value: "2.3%", delta: -1.4, spark: [3.9, 3.6, 3.3, 3.0, 2.8, 2.5, 2.3] },
    },
  },
  {
    id: "aov",
    label: "Avg. Order Value",
    hint: "checkout · USD",
    positiveIsGood: true,
    data: {
      "7D": { value: "$126.40", delta: -1.2, spark: [129, 128, 129, 127, 128, 127, 126.4] },
      "30D": { value: "$126.40", delta: -3.4, spark: [132, 131, 130, 129, 128, 127, 126.4] },
      "90D": { value: "$126.40", delta: 5.6, spark: [119, 121, 120, 123, 124, 125, 126.4] },
    },
  },
];

function Sparkline({
  points,
  good,
  animate,
  uid,
}: {
  points: number[];
  good: boolean;
  animate: boolean;
  uid: string;
}) {
  const W = 148;
  const H = 46;
  const P = 4;

  const { line, area, last } = useMemo(() => {
    const min = Math.min(...points);
    const max = Math.max(...points);
    const range = max - min || 1;
    const step = (W - P * 2) / (points.length - 1);
    const coords = points.map((v, i) => {
      const x = P + i * step;
      const y = P + (H - P * 2) * (1 - (v - min) / range);
      return [x, y] as const;
    });
    const l = coords.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" ");
    const first = coords[0];
    const lst = coords[coords.length - 1];
    const a =
      `M ${first[0].toFixed(2)},${(H - P).toFixed(2)} ` +
      coords.map(([x, y]) => `L ${x.toFixed(2)},${y.toFixed(2)}`).join(" ") +
      ` L ${lst[0].toFixed(2)},${(H - P).toFixed(2)} Z`;
    return { line: l, area: a, last: lst };
  }, [points]);

  const stroke = good ? "#10b981" : "#f43f5e";

  return (
    <svg
      viewBox={`0 0 ${W} ${H}`}
      width="100%"
      height={H}
      preserveAspectRatio="none"
      role="img"
      aria-hidden="true"
      className="overflow-visible"
    >
      <defs>
        <linearGradient id={`${uid}-fill`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
          <stop offset="100%" stopColor={stroke} stopOpacity="0" />
        </linearGradient>
      </defs>
      <path d={area} fill={`url(#${uid}-fill)`} className={animate ? "cardstat-area" : ""} />
      <polyline
        points={line}
        fill="none"
        stroke={stroke}
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
        pathLength={1}
        className={animate ? "cardstat-draw" : ""}
      />
      <circle cx={last[0]} cy={last[1]} r={3} fill={stroke} className={animate ? "cardstat-dot" : ""} />
      <circle cx={last[0]} cy={last[1]} r={3} fill="none" stroke={stroke} strokeOpacity="0.35" strokeWidth={2}>
        {animate ? (
          <animate attributeName="r" from="3" to="7" dur="1.6s" begin="0.8s" repeatCount="indefinite" />
        ) : null}
        {animate ? (
          <animate
            attributeName="stroke-opacity"
            from="0.35"
            to="0"
            dur="1.6s"
            begin="0.8s"
            repeatCount="indefinite"
          />
        ) : null}
      </circle>
    </svg>
  );
}

function TrendBadge({ delta, good }: { delta: number; good: boolean }) {
  const up = delta >= 0;
  return (
    <span
      className={[
        "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums",
        good
          ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400"
          : "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-400",
      ].join(" ")}
    >
      <svg
        viewBox="0 0 16 16"
        width="12"
        height="12"
        fill="none"
        aria-hidden="true"
        className={up ? "" : "rotate-180"}
      >
        <path
          d="M8 13V3M8 3L4 7M8 3l4 4"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
      {up ? "+" : "−"}
      {Math.abs(delta).toFixed(1)}%
    </span>
  );
}

function PinButton({ metricLabel }: { metricLabel: string }) {
  const [pinned, setPinned] = useState(false);
  return (
    <button
      type="button"
      aria-pressed={pinned}
      aria-label={pinned ? `Unpin ${metricLabel} from dashboard` : `Pin ${metricLabel} to dashboard`}
      onClick={() => setPinned((p) => !p)}
      className={[
        "grid h-8 w-8 shrink-0 place-items-center rounded-lg border transition",
        "focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
        "focus-visible:ring-indigo-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
        pinned
          ? "border-indigo-300 bg-indigo-50 text-indigo-600 dark:border-indigo-500/40 dark:bg-indigo-500/15 dark:text-indigo-400"
          : "border-slate-200 bg-white text-slate-400 hover:text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500 dark:hover:text-slate-300",
      ].join(" ")}
    >
      <svg viewBox="0 0 24 24" width="15" height="15" fill={pinned ? "currentColor" : "none"} aria-hidden="true">
        <path
          d="M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6z M12 15v5"
          stroke="currentColor"
          strokeWidth="1.8"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    </button>
  );
}

function StatCard({
  metric,
  timeframe,
  animate,
}: {
  metric: Metric;
  timeframe: Timeframe;
  animate: boolean;
}) {
  const uid = useId().replace(/[:]/g, "");
  const point = metric.data[timeframe];
  const good = point.delta >= 0 === metric.positiveIsGood;

  return (
    <div
      className={[
        "group relative flex flex-col gap-4 overflow-hidden rounded-2xl border p-5 transition duration-300",
        "border-slate-200 bg-white shadow-sm hover:-translate-y-0.5 hover:shadow-lg",
        "dark:border-slate-800 dark:bg-slate-900 dark:shadow-none dark:hover:border-slate-700",
      ].join(" ")}
    >
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-slate-300/70 to-transparent dark:via-slate-600/60"
      />
      <div className="flex items-start justify-between gap-3">
        <div className="min-w-0">
          <p className="truncate text-sm font-medium text-slate-600 dark:text-slate-300">{metric.label}</p>
          <p className="mt-0.5 text-xs text-slate-400 dark:text-slate-500">{metric.hint}</p>
        </div>
        <PinButton metricLabel={metric.label} />
      </div>

      <div className="flex items-end justify-between gap-3">
        <p className="text-3xl font-semibold tracking-tight tabular-nums text-slate-900 dark:text-white">
          {point.value}
        </p>
        <TrendBadge delta={point.delta} good={good} />
      </div>

      <div className="-mx-1">
        <Sparkline key={`${metric.id}-${timeframe}`} points={point.spark} good={good} animate={animate} uid={uid} />
      </div>

      <p className="text-xs text-slate-400 dark:text-slate-500">
        vs. previous{" "}
        {timeframe === "7D" ? "7 days" : timeframe === "30D" ? "30 days" : "90 days"}
      </p>
    </div>
  );
}

export default function CardStat() {
  const reduce = useReducedMotion();
  const animate = !reduce;
  const [timeframe, setTimeframe] = useState<Timeframe>("30D");

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 sm:py-20">
      <style>{`
        .cardstat-draw {
          stroke-dasharray: 1;
          stroke-dashoffset: 1;
          animation: cardstat-draw 1.1s cubic-bezier(0.65, 0, 0.35, 1) forwards;
        }
        .cardstat-area {
          opacity: 0;
          animation: cardstat-area 0.7s ease-out 0.55s forwards;
        }
        .cardstat-dot {
          opacity: 0;
          animation: cardstat-area 0.4s ease-out 0.95s forwards;
        }
        @keyframes cardstat-draw { to { stroke-dashoffset: 0; } }
        @keyframes cardstat-area { to { opacity: 1; } }
        @media (prefers-reduced-motion: reduce) {
          .cardstat-draw, .cardstat-area, .cardstat-dot {
            animation: none;
            stroke-dashoffset: 0;
            opacity: 1;
          }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
              Growth overview
            </h2>
            <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
              Key metrics across your workspace, updated hourly.
            </p>
          </div>

          <div
            role="group"
            aria-label="Select comparison timeframe"
            className="inline-flex self-start rounded-xl border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-900 sm:self-auto"
          >
            {TIMEFRAMES.map((tf) => {
              const active = tf === timeframe;
              return (
                <button
                  key={tf}
                  type="button"
                  aria-pressed={active}
                  onClick={() => setTimeframe(tf)}
                  className={[
                    "rounded-lg px-3.5 py-1.5 text-sm font-medium tabular-nums transition",
                    "focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
                    "focus-visible:ring-indigo-500 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-900",
                    active
                      ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                      : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
                  ].join(" ")}
                >
                  {tf}
                </button>
              );
            })}
          </div>
        </div>

        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
          {METRICS.map((metric) => (
            <StatCard key={metric.id} metric={metric} timeframe={timeframe} animate={animate} />
          ))}
        </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 →