12 KiB
12 KiB
KPI Card Design Patterns
Table of Contents
- Essential Elements
- Number Formatting
- Trend Indicators
- Sparkline Integration
- Card Variations
- Interactive Features
- Accessibility
Essential Elements
Basic KPI Card Structure
interface KPICard {
label: string; // "Monthly Revenue"
value: number; // 1245832
unit?: string; // "$", "%", "users"
trend?: {
direction: 'up' | 'down' | 'neutral';
value: number; // 15.3
comparison: string; // "vs last month"
};
sparkline?: number[]; // [100, 120, 115, 140, 155]
status?: 'success' | 'warning' | 'error' | 'neutral';
icon?: ReactNode;
onClick?: () => void;
}
Visual Hierarchy
┌──────────────────────────────┐
│ 👥 Active Users [ℹ️] [⚙️] │ ← Icon, Label, Actions
│ │
│ 12,543 │ ← Primary Value (largest)
│ │
│ ↑ 234 (+1.9%) │ ← Change (medium)
│ vs yesterday │ ← Comparison (small)
│ │
│ ▂▃▅▄▆▇█▆▅ Last 7 days │ ← Sparkline + Period
└──────────────────────────────┘
Number Formatting
Large Number Abbreviation
function formatLargeNumber(value: number): string {
const abbreviations = [
{ threshold: 1e9, suffix: 'B' },
{ threshold: 1e6, suffix: 'M' },
{ threshold: 1e3, suffix: 'K' }
];
for (const { threshold, suffix } of abbreviations) {
if (Math.abs(value) >= threshold) {
const formatted = (value / threshold).toFixed(1);
return `${formatted}${suffix}`;
}
}
return value.toLocaleString();
}
// Examples:
// 1234567 → "1.2M"
// 45678 → "45.7K"
// 999 → "999"
Currency Formatting
function formatCurrency(value: number, currency = 'USD'): string {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
notation: value > 1e6 ? 'compact' : 'standard'
});
return formatter.format(value);
}
// Examples:
// 1234567 → "$1.2M"
// 45678 → "$45,678"
// 999.99 → "$1,000"
Percentage Formatting
function formatPercentage(value: number, decimals = 1): string {
const formatted = value.toFixed(decimals);
const sign = value > 0 ? '+' : '';
return `${sign}${formatted}%`;
}
// Examples:
// 15.345 → "+15.3%"
// -2.1 → "-2.1%"
// 0 → "0.0%"
Trend Indicators
Direction Arrows
function TrendArrow({ direction, value }: TrendProps) {
const arrows = {
up: '↑',
down: '↓',
neutral: '→'
};
const colors = {
up: 'text-green-600',
down: 'text-red-600',
neutral: 'text-gray-500'
};
return (
<span className={`flex items-center ${colors[direction]}`}>
<span className="text-lg">{arrows[direction]}</span>
<span className="ml-1">{formatPercentage(value)}</span>
</span>
);
}
Status Badges
function StatusBadge({ deltaType, value, label }: BadgeProps) {
const styles = {
increase: 'bg-green-100 text-green-800',
decrease: 'bg-red-100 text-red-800',
neutral: 'bg-gray-100 text-gray-800'
};
return (
<div className={`inline-flex items-center px-2 py-1 rounded ${styles[deltaType]}`}>
<TrendArrow direction={deltaType} />
<span className="ml-1">{value}</span>
{label && <span className="ml-2 text-sm">{label}</span>}
</div>
);
}
Sparkline Integration
Basic Sparkline
import { Sparklines, SparklinesLine, SparklinesSpots } from 'react-sparklines';
function KPISparkline({ data, color = '#10b981' }) {
return (
<Sparklines data={data} width={100} height={30} margin={2}>
<SparklinesLine
style={{
strokeWidth: 2,
stroke: color,
fill: 'none'
}}
/>
<SparklinesSpots
size={2}
style={{ fill: color }}
/>
</Sparklines>
);
}
Inline SVG Sparkline (No Library)
function MiniSparkline({ data, width = 100, height = 30 }) {
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const points = data.map((value, index) => {
const x = (index / (data.length - 1)) * width;
const y = height - ((value - min) / range) * height;
return `${x},${y}`;
}).join(' ');
return (
<svg width={width} height={height} className="sparkline">
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
);
}
Card Variations
Compact KPI Card
function CompactKPI({ label, value, trend }) {
return (
<div className="flex items-center justify-between p-3">
<div>
<p className="text-xs text-gray-600">{label}</p>
<p className="text-lg font-semibold">{value}</p>
</div>
{trend && (
<div className="text-right">
<TrendArrow {...trend} />
</div>
)}
</div>
);
}
Detailed KPI Card
function DetailedKPI({
label,
value,
trend,
sparkline,
breakdown,
actions
}) {
return (
<Card className="p-4">
{/* Header */}
<div className="flex justify-between mb-3">
<h3 className="text-sm font-medium text-gray-600">{label}</h3>
<div className="flex gap-2">
{actions?.map(action => (
<IconButton key={action.id} {...action} />
))}
</div>
</div>
{/* Main Value */}
<div className="mb-3">
<p className="text-3xl font-bold">{value}</p>
{trend && <TrendIndicator {...trend} />}
</div>
{/* Sparkline */}
{sparkline && (
<div className="mb-3">
<MiniSparkline data={sparkline} />
</div>
)}
{/* Breakdown */}
{breakdown && (
<div className="border-t pt-3">
{breakdown.map(item => (
<div key={item.label} className="flex justify-between text-sm">
<span className="text-gray-600">{item.label}</span>
<span className="font-medium">{item.value}</span>
</div>
))}
</div>
)}
</Card>
);
}
Goal-Based KPI Card
function GoalKPI({ label, current, target, unit = '' }) {
const percentage = (current / target) * 100;
const status = percentage >= 100 ? 'success' :
percentage >= 75 ? 'warning' : 'error';
return (
<Card className="p-4">
<h3 className="text-sm text-gray-600 mb-2">{label}</h3>
<div className="flex items-baseline gap-2 mb-3">
<span className="text-2xl font-bold">
{unit}{current.toLocaleString()}
</span>
<span className="text-sm text-gray-500">
of {unit}{target.toLocaleString()}
</span>
</div>
<ProgressBar value={percentage} status={status} />
<p className="text-sm mt-2">
{percentage.toFixed(1)}% of goal achieved
</p>
</Card>
);
}
Interactive Features
Clickable KPI with Drill-Down
function InteractiveKPI({ data, onDrillDown }) {
const [isHovered, setIsHovered] = useState(false);
return (
<Card
className="p-4 cursor-pointer transition-shadow"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={() => onDrillDown(data.id)}
style={{
boxShadow: isHovered ? 'var(--shadow-lg)' : 'var(--shadow-md)'
}}
>
<div className="flex justify-between items-start">
<div>
<p className="text-sm text-gray-600">{data.label}</p>
<p className="text-2xl font-bold mt-1">{data.value}</p>
</div>
{isHovered && (
<ChevronRight className="text-gray-400" />
)}
</div>
{data.trend && (
<div className="mt-3">
<TrendIndicator {...data.trend} />
</div>
)}
</Card>
);
}
Real-Time Updating KPI
function LiveKPI({ endpoint, label }) {
const [data, setData] = useState(null);
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
const eventSource = new EventSource(endpoint);
eventSource.onmessage = (event) => {
setIsUpdating(true);
const newData = JSON.parse(event.data);
setTimeout(() => {
setData(newData);
setIsUpdating(false);
}, 300);
};
return () => eventSource.close();
}, [endpoint]);
return (
<Card className={`p-4 ${isUpdating ? 'animate-pulse' : ''}`}>
<p className="text-sm text-gray-600">{label}</p>
<p className="text-2xl font-bold mt-1">
{data?.value || '---'}
</p>
{data?.lastUpdated && (
<p className="text-xs text-gray-400 mt-2">
Updated {formatRelativeTime(data.lastUpdated)}
</p>
)}
</Card>
);
}
Accessibility
ARIA Labels and Roles
function AccessibleKPI({ label, value, trend }) {
const trendDescription = trend
? `${trend.direction} ${trend.value}% ${trend.comparison}`
: '';
return (
<div
role="region"
aria-label={`${label} metric`}
className="kpi-card"
>
<h3 id={`kpi-${label}-title`}>{label}</h3>
<div
aria-labelledby={`kpi-${label}-title`}
aria-describedby={`kpi-${label}-trend`}
>
<span className="sr-only">Current value:</span>
<span className="text-2xl font-bold">{value}</span>
</div>
{trend && (
<div id={`kpi-${label}-trend`}>
<span className="sr-only">Trend:</span>
<span aria-label={trendDescription}>
<TrendArrow {...trend} />
</span>
</div>
)}
</div>
);
}
Keyboard Navigation
function KeyboardNavigableKPI({ data, onSelect }) {
return (
<div
tabIndex={0}
role="button"
aria-pressed={false}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(data);
}
}}
onClick={() => onSelect(data)}
className="kpi-card focus:ring-2 focus:ring-blue-500"
>
{/* KPI content */}
</div>
);
}
Complete KPI Card Implementation
function KPICard({
label,
value,
unit = '',
trend,
sparkline,
status = 'neutral',
icon,
onClick,
className = ''
}) {
const formattedValue = typeof value === 'number'
? formatLargeNumber(value)
: value;
const statusColors = {
success: 'border-l-4 border-green-500',
warning: 'border-l-4 border-yellow-500',
error: 'border-l-4 border-red-500',
neutral: ''
};
return (
<Card
className={`p-4 ${statusColors[status]} ${onClick ? 'cursor-pointer' : ''} ${className}`}
onClick={onClick}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{icon && <span className="text-gray-500">{icon}</span>}
<h3 className="text-sm font-medium text-gray-600">{label}</h3>
</div>
</div>
{/* Value */}
<div className="mb-3">
<p className="text-2xl font-bold">
{unit && <span className="text-lg">{unit}</span>}
{formattedValue}
</p>
</div>
{/* Trend */}
{trend && (
<div className="flex items-center gap-2 mb-3">
<TrendArrow direction={trend.direction} />
<span className="text-sm font-medium">
{formatPercentage(trend.value)}
</span>
<span className="text-sm text-gray-500">
{trend.comparison}
</span>
</div>
)}
{/* Sparkline */}
{sparkline && sparkline.length > 0 && (
<div className="mt-3">
<MiniSparkline data={sparkline} />
</div>
)}
</Card>
);
}