Componentdata3.26 KB gzipZero dependencies

Metric Card

Dashboard KPI card with value, trend delta and period label.

Preview

Live Preview
MET-01-MRRREALTIME

MONTHLY REVENUE

$48,250
+14.8%vs last month
Refreshed 2m ago
MET-02-PASSAUDITED

ACTIVE PASSES

1,420TIERS
+8.2%vs last 30d
Target: 1,80078%
Active turnstiles: 14
MET-03-LATOPTIMAL

GATE LATENCY

18ms
-12.4%vs yesterday
Global edge p99

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add metric-card

Or install the full npm package:

npm install bigbullui
import { MetricCard } from "@/components/ui/metric-card";

<MetricCard
  title="MONTHLY RECURRING REVENUE"
  value="$48,250"
  trend={{ value: "+14.8%", label: "vs last month" }}
  sparklineData={[32, 38, 35, 42, 40, 48, 52, 59, 64]}
  badge="REALTIME"
/>

Usage guidance

Use Metric Card when a ticket stub surface needs Dashboard KPI card with value, trend delta and period label. Keep props minimal and prefer composition with sibling stubs.

Avoid Metric Card for long form flows or dense data grids where a dedicated layout block fits better.

Source code

metric-card.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export type TrendDirection = "up" | "down" | "neutral";

export interface MetricCardTrend {
  value: string | number;
  direction?: TrendDirection;
  label?: string;
  invertColors?: boolean;
}

export type MetricCardProps = Omit<React.HTMLAttributes<HTMLDivElement>, "prefix"> & {
  title: string;
  value: string | number;
  prefix?: React.ReactNode;
  suffix?: React.ReactNode;
  trend?: MetricCardTrend;
  periodLabel?: string;
  badge?: string;
  serial?: string;
  sparklineData?: number[];
  sparklineTone?: "accent" | "foreground" | "success" | "muted";
  progress?: number;
  progressTarget?: string;
  statusDot?: "active" | "warning" | "error" | "idle";
  footerText?: string;
  barcodeValue?: string;
  className?: string;
}

export function MetricCard({
  title,
  value,
  prefix,
  suffix,
  trend,
  periodLabel,
  badge = "LIVE AUDIT",
  serial,
  sparklineData,
  sparklineTone = "accent",
  progress,
  progressTarget,
  statusDot = "active",
  footerText,
  barcodeValue,
  className,
  ...props
}: MetricCardProps) {
  const gradientId = React.useId();

  // Auto detect trend direction if not explicitly provided
  const trendDirection: TrendDirection = React.useMemo(() => {
    if (trend?.direction) return trend.direction;
    if (!trend?.value) return "neutral";
    const strVal = String(trend.value).trim();
    if (strVal.startsWith("+")) return "up";
    if (strVal.startsWith("-")) return "down";
    const numVal = parseFloat(strVal);
    if (!isNaN(numVal)) {
      if (numVal > 0) return "up";
      if (numVal < 0) return "down";
    }
    return "neutral";
  }, [trend?.direction, trend?.value]);

  const isPositive = trendDirection === "up";
  const isNegative = trendDirection === "down";
  const invert = Boolean(trend?.invertColors);

  // Invert colors if lower is better (e.g. churn or latency)
  const isGood = invert ? isNegative : isPositive;
  const isBad = invert ? isPositive : isNegative;

  const trendValue = trend?.value;
  const trendDirectionProp = trend?.direction;

  // Format trend string display
  const formattedTrendValue = React.useMemo(() => {
    if (!trendValue) return "";
    const str = String(trendValue).trim();
    if (trendDirection === "up" && !str.startsWith("+")) {
      return `+${str}`;
    }
    return str;
  }, [trendValue, trendDirection]);

  // Generate SVG path for sparkline
  const sparklinePath = React.useMemo(() => {
    if (!sparklineData || sparklineData.length < 2) return null;
    const width = 120;
    const height = 36;
    const padY = 4;
    const padX = 2;

    const min = Math.min(...sparklineData);
    const max = Math.max(...sparklineData);
    const range = max - min || 1;

    const innerWidth = width - padX * 2;
    const innerHeight = height - padY * 2;

    const pts = sparklineData.map((v, i) => {
      const x = padX + (i / (sparklineData.length - 1)) * innerWidth;
      const y = height - padY - ((v - min) / range) * innerHeight;
      return { x, y };
    });

    const d = pts.reduce((acc, pt, i) => {
      return i === 0 ? `M ${pt.x} ${pt.y}` : `${acc} L ${pt.x} ${pt.y}`;
    }, "");

    const lastPt = pts[pts.length - 1];
    const areaD = `${d} L ${lastPt.x} ${height} L ${pts[0].x} ${height} Z`;

    return { d, areaD, lastPt, width, height };
  }, [sparklineData]);

  const strokeColor =
    sparklineTone === "accent"
      ? "var(--color-accent)"
      : sparklineTone === "success"
      ? "var(--color-success)"
      : sparklineTone === "muted"
      ? "var(--color-muted-foreground)"
      : "currentColor";

  return (
    <div
      className={cn(
        "relative flex flex-col rounded-xl border-2 border-foreground bg-card shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md overflow-hidden select-none",
        className
      )}
      {...props}
    >
      {/* Top Header Section */}
      <div className="p-5 pb-3">
        <div className="flex items-center justify-between gap-2">
          {/* Micro Serial & Category */}
          <span className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground truncate">
            {serial || "STUB // KPI"}
          </span>

          {/* Live Status Badge */}
          {badge && (
            <span className="inline-flex items-center gap-1.5 rounded-xs border border-dashed border-border bg-secondary/40 px-2 py-0.5 font-mono text-[9px] font-bold uppercase tracking-wider text-foreground shrink-0">
              {statusDot === "active" && (
                <span className="size-1.5 rounded-full bg-accent animate-ping shrink-0" />
              )}
              {statusDot === "warning" && (
                <span className="size-1.5 rounded-full bg-warning shrink-0" />
              )}
              {statusDot === "error" && (
                <span className="size-1.5 rounded-full bg-destructive shrink-0" />
              )}
              {statusDot === "idle" && (
                <span className="size-1.5 rounded-full bg-muted-foreground shrink-0" />
              )}
              <span>{badge}</span>
            </span>
          )}
        </div>

        {/* Title */}
        <h4 className="mt-2 font-mono text-xs font-bold uppercase tracking-wider text-muted-foreground">
          {title}
        </h4>

        {/* Value Display */}
        <div className="mt-2 flex items-baseline gap-1">
          {prefix && (
            <span className="font-mono text-xl font-bold text-muted-foreground">
              {prefix}
            </span>
          )}
          <span className="font-mono text-3xl sm:text-4xl font-black tracking-tight text-foreground">
            {value}
          </span>
          {suffix && (
            <span className="font-mono text-xs font-semibold text-muted-foreground ms-0.5">
              {suffix}
            </span>
          )}
        </div>

        {/* Trend & Period Comparison */}
        {(trend || periodLabel) && (
          <div className="mt-3 flex flex-wrap items-center gap-2">
            {trend && (
              <span
                className={cn(
                  "inline-flex items-center gap-1 rounded-xs border border-dashed px-1.5 py-0.5 font-mono text-[11px] font-bold rotate-[-1deg]",
                  isGood &&
                    "border-success/40 bg-success/10 text-success",
                  isBad &&
                    "border-destructive/40 bg-destructive/10 text-destructive",
                  !isGood &&
                    !isBad &&
                    "border-border bg-secondary/50 text-muted-foreground"
                )}
              >
                {trendDirection === "up" && (
                  <svg
                    className="size-3 shrink-0"
                    viewBox="0 0 16 16"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2.2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M4 12 12 4M6 4h6v6" />
                  </svg>
                )}
                {trendDirection === "down" && (
                  <svg
                    className="size-3 shrink-0"
                    viewBox="0 0 16 16"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2.2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M4 4l8 8M6 12h6V6" />
                  </svg>
                )}
                {trendDirection === "neutral" && (
                  <span className="font-mono text-[10px]" aria-hidden="true">
                    —
                  </span>
                )}
                <span>{formattedTrendValue}</span>
              </span>
            )}

            {(trend?.label || periodLabel) && (
              <span className="font-mono text-[10px] text-muted-foreground uppercase tracking-tight">
                {trend?.label || periodLabel}
              </span>
            )}
          </div>
        )}
      </div>

      {/* Perforation Divider with Punch Notches */}
      <div className="relative flex items-center justify-between py-1">
        {/* Left Cutout Hole */}
        <div
          className="size-4 rounded-full bg-background border-2 border-foreground -ms-2 shrink-0"
          aria-hidden="true"
        />

        {/* Dashed Perforation Line */}
        <div className="h-0 flex-1 border-t-2 border-dashed border-border" />

        {/* Right Cutout Hole */}
        <div
          className="size-4 rounded-full bg-background border-2 border-foreground -me-2 shrink-0"
          aria-hidden="true"
        />
      </div>

      {/* Bottom Visual Graphic / Stub Area */}
      <div className="p-5 pt-3 bg-secondary/20 flex flex-col justify-between flex-1 gap-3">
        {/* Sparkline Graphic */}
        {sparklinePath && (
          <div className="w-full">
            <svg
              viewBox={`0 0 ${sparklinePath.width} ${sparklinePath.height}`}
              className="w-full h-10 overflow-visible"
              aria-label="Metric trend visualization"
              role="img"
            >
              <defs>
                <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
                  <stop offset="0%" stopColor={strokeColor} stopOpacity="0.25" />
                  <stop offset="100%" stopColor={strokeColor} stopOpacity="0.0" />
                </linearGradient>
              </defs>

              {/* Dashed baseline */}
              <line
                x1={0}
                y1={sparklinePath.height - 1}
                x2={sparklinePath.width}
                y2={sparklinePath.height - 1}
                stroke="currentColor"
                strokeOpacity="0.2"
                strokeDasharray="2 2"
              />

              {/* Gradient area */}
              <path
                d={sparklinePath.areaD}
                fill={`url(#${gradientId})`}
              />

              {/* Trend line */}
              <path
                d={sparklinePath.d}
                fill="none"
                stroke={strokeColor}
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              />

              {/* Endpoint Pulsing Indicator */}
              <circle
                cx={sparklinePath.lastPt.x}
                cy={sparklinePath.lastPt.y}
                r="3"
                fill={strokeColor}
              />
              <circle
                cx={sparklinePath.lastPt.x}
                cy={sparklinePath.lastPt.y}
                r="5"
                fill={strokeColor}
                opacity="0.3"
                className="animate-ping"
              />
            </svg>
          </div>
        )}

        {/* Progress Bar Graphic */}
        {progress !== undefined && (
          <div className="space-y-1.5 w-full">
            <div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground font-bold">
              <span>{progressTarget || "CYCLE PROGRESS"}</span>
              <span>{Math.min(100, Math.max(0, progress))}%</span>
            </div>
            <div className="h-2 w-full rounded-xs border border-dashed border-foreground/30 bg-muted overflow-hidden p-0.5">
              <div
                className="h-full bg-accent rounded-xs transition-all duration-500"
                style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
              />
            </div>
          </div>
        )}

        {/* Micro Barcode & Timestamp Footer */}
        <div className="flex items-center justify-between text-[9px] font-mono text-muted-foreground border-t border-dashed border-border/70 pt-2.5">
          <span className="truncate pe-2">
            {footerText || "VALIDATED EMISSION"}
          </span>

          {/* Mini Barcode Graphic */}
          <div className="flex items-center gap-0.5 shrink-0 opacity-70" aria-hidden="true">
            <div className="w-0.5 h-3.5 bg-foreground" />
            <div className="w-1 h-3.5 bg-foreground" />
            <div className="w-0.5 h-3.5 bg-transparent" />
            <div className="w-0.5 h-3.5 bg-foreground" />
            <div className="w-1.5 h-3.5 bg-foreground" />
            <div className="w-0.5 h-3.5 bg-transparent" />
            <div className="w-0.5 h-3.5 bg-foreground" />
            <div className="w-1 h-3.5 bg-foreground" />
            <div className="w-0.5 h-3.5 bg-foreground" />
            {barcodeValue && (
              <span className="ms-1 text-[8px] font-mono uppercase tracking-widest hidden sm:inline">
                {barcodeValue}
              </span>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

Props

PropTypeDescription
titlestringMetric title or KPI description (required).
valuestring | numberPrimary large KPI metric value (required).
prefixReact.ReactNodePrefix symbol displayed before value (e.g. '$').
suffixReact.ReactNodeSuffix unit displayed after value (e.g. '/mo', 'ms').
trendMetricCardTrendTrend object with value (+12.4%), direction ('up' | 'down' | 'neutral'), label, and invertColors.
periodLabelstringPeriod comparison label (e.g. 'ROLLING 30 DAYS').
badgestringEyebrow status badge text (default 'LIVE AUDIT').
serialstringTicket stub serial number label (e.g. 'STUB // KPI-01').
sparklineDatanumber[]Array of numeric datapoints to render an inline SVG trend line.
sparklineTone"accent" | "foreground" | "success" | "muted"Color tone for sparkline stroke and fill.
progressnumberProgress bar percentage (0 to 100).
progressTargetstringTarget label displayed above progress bar.
statusDot"active" | "warning" | "error" | "idle"Status dot indicator in the header badge.
footerTextstringBottom validation or source stamp text.
barcodeValuestringSerial readout displayed next to mini SVG barcode.