A flexible metrics dashboard component inspired by Vercel. Shows key numbers with trend indicators, loading skeletons, and responsive column layouts.
Website Analytics
Revenue Metrics
// Dependencies: react ^18, lucide-react
import React from 'react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
type StatItem = {
id: string;
label: string;
value: string | number;
change?: number;
changeLabel?: string;
trend?: 'up' | 'down' | 'neutral';
prefix?: string;
suffix?: string;
icon?: LucideIcon;
};
type Props = {
stats: StatItem[];
columns?: 2 | 3 | 4;
loading?: boolean;
};
const colClass: Record<number, string> = {
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
};
export function StatsRow({ stats, columns = 4, loading = false }: Props) {
return (
<div className={`grid gap-3 ${colClass[columns]}`}>
{loading
? Array.from({ length: columns }).map((_, i) => <SkeletonCard key={i} />)
: stats.map((stat) => <StatCard key={stat.id} stat={stat} />)}
</div>
);
}
function StatCard({ stat }: { stat: StatItem }) {
const Icon = stat.icon;
const hasTrend = stat.trend !== undefined && stat.change !== undefined;
return (
<div className="group flex flex-col gap-3 rounded-xl border border-[#2a2a2a] bg-[#111111] p-4 hover:-translate-y-0.5 hover:border-[#3a3a3a] transition-all duration-200">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 font-medium">{stat.label}</span>
{Icon && (
<div className="w-7 h-7 rounded-lg bg-zinc-900 border border-[#2a2a2a] flex items-center justify-center">
<Icon className="w-3.5 h-3.5 text-zinc-500" />
</div>
)}
</div>
<div className="flex items-baseline gap-1">
{stat.prefix && <span className="text-sm text-zinc-500">{stat.prefix}</span>}
<span className="text-3xl font-bold text-white tracking-tight leading-none">
{stat.value}
</span>
{stat.suffix && <span className="text-sm text-zinc-500">{stat.suffix}</span>}
</div>
<div className="flex items-center gap-1.5 min-h-[18px]">
{hasTrend ? (
<>
{stat.trend === 'up' && <TrendingUp className="w-3.5 h-3.5 text-emerald-400 shrink-0" />}
{stat.trend === 'down' && <TrendingDown className="w-3.5 h-3.5 text-red-400 shrink-0" />}
{stat.trend === 'neutral' && <Minus className="w-3.5 h-3.5 text-zinc-500 shrink-0" />}
<span
className={`text-xs font-semibold ${
stat.trend === 'up' ? 'text-emerald-400'
: stat.trend === 'down' ? 'text-red-400'
: 'text-zinc-500'
}`}
>
{stat.change! > 0 ? '+' : ''}{stat.change}%
</span>
{stat.changeLabel && (
<span className="text-xs text-zinc-600">{stat.changeLabel}</span>
)}
</>
) : (
<span className="text-xs text-zinc-700">—</span>
)}
</div>
</div>
);
}
function SkeletonCard() {
return (
<div className="flex flex-col gap-3 rounded-xl border border-[#2a2a2a] bg-[#111111] p-4">
<div className="flex items-center justify-between">
<div className="h-2.5 w-20 rounded bg-zinc-800 animate-pulse" />
<div className="w-7 h-7 rounded-lg bg-zinc-800 animate-pulse" />
</div>
<div className="h-8 w-28 rounded bg-zinc-800 animate-pulse" />
<div className="h-2.5 w-24 rounded bg-zinc-800 animate-pulse" />
</div>
);
}
// Usage example:
// const stats = [
// { id: 'visits', label: 'Total Visits', value: '24,521', change: 12.5, changeLabel: 'vs last month', trend: 'up' },
// { id: 'mrr', label: 'MRR', value: '4,290', prefix: '$', change: 22.4, trend: 'up' },
// ];
// <StatsRow stats={stats} columns={4} loading={false} />Unlock to copy
Free access to all patterns