Componentdata
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-cardOr install the full npm package:
npm install bigbulluiUsage
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"
/>Source code
metric-card.tsxZero 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;
// Format trend string display
const formattedTrendValue = React.useMemo(() => {
if (!trend?.value) return "";
const str = String(trend.value).trim();
if (trendDirection === "up" && !str.startsWith("+")) {
return `+${str}`;
}
return str;
}, [trend?.value, 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, #bc3a28)"
: sparklineTone === "success"
? "#10b981"
: sparklineTone === "muted"
? "var(--color-muted-foreground, #6f6350)"
: "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-amber-500 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 ml-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-emerald-600/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
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 -ml-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 -mr-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/80 border-t border-dashed border-border/70 pt-2.5">
<span className="truncate pr-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="ml-1 text-[8px] font-mono uppercase tracking-widest hidden sm:inline">
{barcodeValue}
</span>
)}
</div>
</div>
</div>
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| title | string | Metric title or KPI description (required). |
| value | string | number | Primary large KPI metric value (required). |
| prefix | React.ReactNode | Prefix symbol displayed before value (e.g. '$'). |
| suffix | React.ReactNode | Suffix unit displayed after value (e.g. '/mo', 'ms'). |
| trend | MetricCardTrend | Trend object with value (+12.4%), direction ('up' | 'down' | 'neutral'), label, and invertColors. |
| periodLabel | string | Period comparison label (e.g. 'ROLLING 30 DAYS'). |
| badge | string | Eyebrow status badge text (default 'LIVE AUDIT'). |
| serial | string | Ticket stub serial number label (e.g. 'STUB // KPI-01'). |
| sparklineData | number[] | Array of numeric datapoints to render an inline SVG trend line. |
| sparklineTone | "accent" | "foreground" | "success" | "muted" | Color tone for sparkline stroke and fill. |
| progress | number | Progress bar percentage (0 to 100). |
| progressTarget | string | Target label displayed above progress bar. |
| statusDot | "active" | "warning" | "error" | "idle" | Status dot indicator in the header badge. |
| footerText | string | Bottom validation or source stamp text. |
| barcodeValue | string | Serial readout displayed next to mini SVG barcode. |