# KPI Grid

> Hairline-joined grid of KPI tiles with label, value, an up/down/flat delta chip and a sparkline. Ships composable parts.

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/kpi-grid).

- Page, with a live preview: https://shadcn.exchange/r/hirael/kpi-grid
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/kpi-grid-light-f4538433ec31f621.png
- Registry item: https://hirael.com/r/kpi-grid.json
- Categories: blocks, widgets
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://hirael.com/r/kpi-grid.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `https://hirael.com/r/sparkline.json`, `toggle-group`

## Files

### `registry/hirael/bases/radix/blocks/kpi-grid/kpi-grid.tsx`

```tsx
// KPI Grid from Hirael <https://hirael.com/blocks/widgets/kpi-grid>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { Minus, TrendingDown, TrendingUp } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Sparkline, type SparklineProps } from '@/registry/hirael/bases/radix/components/sparkline';
import { ToggleGroup, ToggleGroupItem } from '@/registry/hirael/bases/radix/ui/toggle-group';

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('text-xs text-muted-foreground uppercase', 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 text-xs', trendTone[trend], className)}
      {...props}
    >
      <Icon className="size-3.5" aria-hidden />
      {children}
    </span>
  );
};

interface KpiCardSparkProps extends Omit<SparklineProps, 'data'> {
  points: number[];
}

const KpiCardSpark = ({ points, className, ...props }: KpiCardSparkProps) => {
  if (!points.length) return null;

  return (
    <Sparkline
      data-slot="kpi-card-spark"
      data={points}
      tone="muted"
      aria-hidden
      className={cn('h-7 w-full', className)}
      {...props}
    />
  );
};

export { KpiGrid, KpiCard, KpiCardLabel, KpiCardValue, KpiCardDelta, KpiCardSpark };

const ENTER =
  'animate-in fade-in slide-in-from-bottom-2 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const SWAP =
  'animate-in fade-in slide-in-from-bottom-1 duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

type KpiPeriod = '7d' | '30d' | '90d';

const PERIODS: { value: KpiPeriod; label: string; comparison: string }[] = [
  { value: '7d', label: '7 days', comparison: 'vs previous 7 days' },
  { value: '30d', label: '30 days', comparison: 'vs previous 30 days' },
  { value: '90d', label: '90 days', comparison: 'vs previous quarter' },
];

type KpiReading = { value: string; trend: KpiTrend; delta: string; spark: number[] };

const KPI_ROWS: { key: string; label: string; periods: Record<KpiPeriod, KpiReading> }[] = [
  {
    key: 'revenue',
    label: 'Revenue',
    periods: {
      '7d': { value: '$11.6k', trend: 'up', delta: '+3.2%', spark: [14, 15, 13, 16, 17, 16, 18] },
      '30d': { value: '$48.2k', trend: 'up', delta: '+12.4%', spark: [8, 10, 9, 13, 12, 16, 18] },
      '90d': { value: '$139k', trend: 'up', delta: '+21.8%', spark: [5, 7, 8, 9, 12, 14, 18] },
    },
  },
  {
    key: 'active-users',
    label: 'Active users',
    periods: {
      '7d': { value: '2,480', trend: 'down', delta: '-1.6%', spark: [26, 25, 27, 24, 23, 24, 22] },
      '30d': { value: '3,914', trend: 'up', delta: '+4.1%', spark: [20, 19, 22, 21, 24, 23, 26] },
      '90d': { value: '5,207', trend: 'up', delta: '+9.7%', spark: [14, 16, 17, 19, 21, 22, 26] },
    },
  },
  {
    key: 'churn',
    label: 'Churn',
    periods: {
      '7d': { value: '0.4%', trend: 'flat', delta: '0.0%', spark: [4, 4, 5, 4, 4, 4, 4] },
      '30d': { value: '1.8%', trend: 'down', delta: '-0.3%', spark: [6, 5, 5, 4, 4, 3, 3] },
      '90d': { value: '5.1%', trend: 'down', delta: '-0.9%', spark: [9, 8, 8, 7, 6, 6, 5] },
    },
  },
  {
    key: 'avg-order',
    label: 'Avg. order',
    periods: {
      '7d': { value: '$63.10', trend: 'up', delta: '+2.8%', spark: [11, 12, 12, 13, 12, 14, 15] },
      '30d': { value: '$61.40', trend: 'flat', delta: '0.0%', spark: [12, 13, 12, 12, 13, 12, 12] },
      '90d': { value: '$58.90', trend: 'down', delta: '-4.2%', spark: [16, 15, 15, 14, 13, 13, 12] },
    },
  },
];

const KpiGridBlock = () => {
  const [period, setPeriod] = React.useState<KpiPeriod>('30d');
  const comparison = PERIODS.find((option) => option.value === period)?.comparison;

  return (
    <section data-slot="kpi-grid-block" className="flex w-full justify-center bg-background p-6 sm:p-10">
      <div className={cn(ENTER, 'flex w-full max-w-2xl flex-col gap-3')}>
        <div data-slot="kpi-grid-toolbar" className="flex flex-wrap items-center justify-between gap-2">
          <p key={period} className={cn(SWAP, 'text-xs text-muted-foreground')}>
            {comparison}
          </p>
          <ToggleGroup
            type="single"
            size="sm"
            variant="outline"
            value={period}
            onValueChange={(value) => {
              if (value) setPeriod(value as KpiPeriod);
            }}
            aria-label="Period"
          >
            {PERIODS.map((option) => (
              <ToggleGroupItem key={option.value} value={option.value}>
                {option.label}
              </ToggleGroupItem>
            ))}
          </ToggleGroup>
        </div>
        <KpiGrid>
          {KPI_ROWS.map((kpi, index) => {
            const reading = kpi.periods[period];

            return (
              <KpiCard key={kpi.key}>
                <KpiCardLabel>{kpi.label}</KpiCardLabel>
                <div
                  key={period}
                  style={{ animationDelay: `${index * 40}ms` }}
                  className={cn(SWAP, 'flex flex-col gap-2')}
                >
                  <KpiCardValue className="tabular-nums">{reading.value}</KpiCardValue>
                  <KpiCardDelta trend={reading.trend} className="tabular-nums">
                    {reading.delta}
                  </KpiCardDelta>
                  <KpiCardSpark points={reading.spark} />
                </div>
              </KpiCard>
            );
          })}
        </KpiGrid>
      </div>
    </section>
  );
};

export default KpiGridBlock;
```
