ShadTable
DiscordGitHub

ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

Summary / KPI Table

A compact metrics table — current value, period-over-period change, and a per-row sparkline — for the kind of dashboard summary that sits above the fold.

npx shadcn add https://shadcn-table-library.vercel.app/r/kpi-table.json
MetricValueChangeTrend
Revenue$128,400+8.2%
New Customers482-3.4%
Churn Rate3.1%-0.4%
Conversion Rate4.6%+0.3%
Avg Order Value$64+1.1%

How it works

1.The sparkline is a hand-rolled SVG, not a charting library

Sparkline normalizes a small array of numbers to a 0..height range and joins the points into a single <polyline points="...">. For 8-12 points that's simpler and lighter than pulling in a charting dependency to draw what is, in the end, just one line.

src/components/kpi/sparkline.tsx
const min = Math.min(...data)
const max = Math.max(...data)
const range = max - min || 1
const stepX = width / (data.length - 1)
const points = data
.map((value, i) => {
const x = i * stepX
const y = height - ((value - min) / range) * height
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')

2.Color comes from one number, reused twice

change's sign drives both the TrendingUp/TrendingDown icon and color in the Change column, and the Sparkline's text-emerald/text-rose class in the Trend column. The badge and the sparkline can never disagree about whether a metric is trending up or down — they're colored by the exact same comparison, not two separate ones.

src/components/kpi/columns.tsx
<Sparkline
data={row.original.trend}
className={cn(
'h-7 w-24',
row.original.change >= 0
? 'text-emerald-600 dark:text-emerald-500'
: 'text-rose-600 dark:text-rose-500',
)}
/>

3.The stroke color comes from currentColor, not a prop

Sparkline's polyline sets stroke="currentColor" and takes its actual color from a Tailwind text-color class on the wrapping element — the same trick lucide-react icons use elsewhere in this library. That's how light/dark mode work for the sparkline for free, without the component itself knowing anything about the app's theme.

src/components/kpi/sparkline.tsx
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>

4.No sorting, filtering, or pagination — this is a dashboard widget

With a small, fixed set of KPI rows meant to all be visible at once, getSortedRowModel/getFilteredRowModel/getPaginationRowModel would just be complexity a summary card never needs. getCoreRowModel is the entire row-model pipeline here.

src/components/kpi/data-table.tsx
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})

5.Formatting is unit-aware, driven by each KPI's own unit field

formatKpiValue branches on unit ('currency' | 'percent' | 'number'), so Revenue renders as $128,400, Churn Rate renders as 3.1%, and New Customers renders as a plain 482 — all from one shared formatting function instead of ad hoc .toFixed() calls scattered across column definitions.

src/components/kpi/kpi.ts
export function formatKpiValue(value: number, unit: KpiUnit): string {
if (unit === 'currency') {
return value.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
}
if (unit === 'percent') return `${value.toFixed(1)}%`
return value.toLocaleString('en-US')
}