Skip to content
6

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.json

Code

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