KPI Grid
Hairline-joined grid of KPI tiles with label, value, an up/down/flat delta chip and a dependency-free sparkline. Ships composable parts.
Preview
Installation
npx shadcn@latest add https://hirael.com/r/kpi-grid.jsonCode
components/blocks/kpi-grid.tsx
'use client';
import * as React from 'react';
import { Minus, TrendingDown, TrendingUp } from 'lucide-react';
import { cn } from '@/lib/utils';
type KpiGridProps = React.ComponentProps<'div'>;
const KpiGrid = ({ className, ...props }: KpiGridProps) => {
return (
<div
data-slot="kpi-grid"
className={cn(
'grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border lg:grid-cols-4',
className,
)}
{...props}
/>
);
};
type KpiCardProps = React.ComponentProps<'div'>;
const KpiCard = ({ className, ...props }: KpiCardProps) => {
return <div data-slot="kpi-card" className={cn('flex flex-col gap-2 bg-card p-4', className)} {...props} />;
};
type KpiCardLabelProps = React.ComponentProps<'p'>;
const KpiCardLabel = ({ className, ...props }: KpiCardLabelProps) => {
return (
<p
data-slot="kpi-card-label"
className={cn('font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground', className)}
{...props}
/>
);
};
type KpiCardValueProps = React.ComponentProps<'p'>;
const KpiCardValue = ({ className, ...props }: KpiCardValueProps) => {
return (
<p
data-slot="kpi-card-value"
className={cn('text-2xl font-semibold tracking-[-0.03em] text-foreground', className)}
{...props}
/>
);
};
type KpiTrend = 'up' | 'down' | 'flat';
const trendIcon: Record<KpiTrend, typeof TrendingUp> = {
up: TrendingUp,
down: TrendingDown,
flat: Minus,
};
const trendTone: Record<KpiTrend, string> = {
up: 'text-success',
down: 'text-destructive',
flat: 'text-muted-foreground',
};
interface KpiCardDeltaProps extends Omit<React.ComponentProps<'span'>, 'children'> {
trend?: KpiTrend;
children?: React.ReactNode;
}
const KpiCardDelta = ({ trend = 'flat', className, children, ...props }: KpiCardDeltaProps) => {
const Icon = trendIcon[trend];
return (
<span
data-slot="kpi-card-delta"
data-trend={trend}
className={cn('inline-flex items-center gap-1 font-mono text-xs', trendTone[trend], className)}
{...props}
>
<Icon className="size-3.5" aria-hidden />
{children}
</span>
);
};
interface KpiCardSparkProps extends Omit<React.ComponentProps<'svg'>, 'points'> {
points: number[];
}
const KpiCardSpark = ({ points, className, ...props }: KpiCardSparkProps) => {
if (!points.length) return null;
const max = Math.max(...points);
const min = Math.min(...points);
const range = max - min || 1;
const width = 100;
const height = 28;
const step = points.length > 1 ? width / (points.length - 1) : width;
const line = points.map((point, i) => `${i * step},${height - ((point - min) / range) * height}`).join(' ');
return (
<svg
data-slot="kpi-card-spark"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
aria-hidden
className={cn('h-7 w-full text-muted-foreground', className)}
{...props}
>
<polyline
points={line}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
};
export { KpiGrid, KpiCard, KpiCardLabel, KpiCardValue, KpiCardDelta, KpiCardSpark };
const KPI_ROWS: {
key: string;
label: string;
value: string;
trend: 'up' | 'down' | 'flat';
delta: string;
spark: number[];
}[] = [
{
key: 'revenue',
label: 'Revenue',
value: '$48.2k',
trend: 'up',
delta: '+12.4%',
spark: [8, 10, 9, 13, 12, 16, 18],
},
{
key: 'active-users',
label: 'Active users',
value: '3,914',
trend: 'up',
delta: '+4.1%',
spark: [20, 19, 22, 21, 24, 23, 26],
},
{
key: 'churn',
label: 'Churn',
value: '1.8%',
trend: 'down',
delta: '-0.3%',
spark: [6, 5, 5, 4, 4, 3, 3],
},
{
key: 'avg-order',
label: 'Avg. order',
value: '$61.40',
trend: 'flat',
delta: '0.0%',
spark: [12, 13, 12, 12, 13, 12, 12],
},
];
const KpiGridBlock = () => {
return (
<section data-slot="kpi-grid-block" className="flex w-full justify-center bg-background p-6 sm:p-10">
<KpiGrid className="w-full max-w-2xl">
{KPI_ROWS.map((kpi) => (
<KpiCard key={kpi.key}>
<KpiCardLabel>{kpi.label}</KpiCardLabel>
<KpiCardValue>{kpi.value}</KpiCardValue>
<KpiCardDelta trend={kpi.trend}>{kpi.delta}</KpiCardDelta>
<KpiCardSpark points={kpi.spark} />
</KpiCard>
))}
</KpiGrid>
</section>
);
};
export default KpiGridBlock;
Dependencies
npm
lucide-react