ShadTable
DiscordGitHub

ShadTable

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

Heatmap Table

Revenue by region and month, with each cell's background intensity mapped to its value — good for spotting patterns across a matrix at a glance rather than reading numbers one by one.

npx shadcn add https://shadcn-table-library.vercel.app/r/heatmap-table.json
JanFebMarAprMayJun
North
$42k
$45k
$40k
$51k
$58k
$63k
South
$31k
$33k
$29k
$38k
$41k
$44k
East
$55k
$59k
$52k
$67k
$74k
$81k
West
$38k
$41k
$36k
$46k
$50k
$55k
$29k
$81k

How it works

1.Color intensity comes from one min/max pass over the whole matrix

getHeatmapRange scans every row × column once to find the matrix's global minimum and maximum. With just those two numbers, a single getHeatColor(value, min, max) call can turn any cell's raw revenue figure into a comparable alpha value — the same function runs for all 24 cells in the demo.

src/components/heatmap/heatmap.ts
export function getHeatmapRange(rows: HeatmapRow[], columns: string[]) {
let min = Infinity
let max = -Infinity
for (const row of rows) {
for (const col of columns) {
const value = row.values[col]
if (value < min) min = value
if (value > max) max = value
}
}
return { min, max }
}

2.Normalized globally, not per row

Because min/max come from the whole matrix rather than being recomputed for each row, South's uniformly lower revenue reads as uniformly light and East's uniformly higher revenue reads as uniformly dark. Normalizing per row instead would make every row's own max look equally dark regardless of how the rows compare to each other — exactly the pattern a heatmap exists to show.

src/components/heatmap/data-table.tsx
const { min, max } = useMemo(
() => getHeatmapRange(data, columns), // scans the full matrix, not one row
[data, columns],
)

3.The color fills a div inside the cell, not the TableCell itself

Value TableCells get p-0, and an inner div stretches to h-full w-full with its own padding. That lets the color block fill the entire cell edge-to-edge — if the color were applied straight to TableCell, the table's default cell padding would leave a colorless margin around every value.

src/components/heatmap/data-table.tsx
<TableCell className={cn(cell.column.id === 'label' ? undefined : 'p-0 text-center')}>
{/* cell renders: */}
<div style={{ backgroundColor }} className="flex h-full w-full items-center justify-center px-3 py-2.5">
{formatValue(value)}
</div>
</TableCell>

4.Text flips to white once the background gets dark enough

getHeatColor also returns isDark, true once the computed alpha crosses a fixed threshold. Cells past that threshold render with text-white instead of the default foreground color, so high-value cells (East in June, the darkest cell) stay legible instead of dark text disappearing into a dark background.

src/components/heatmap/heatmap.ts
export function getHeatColor(value: number, min: number, max: number) {
const range = max - min || 1
const alpha = MIN_ALPHA + ((value - min) / range) * (MAX_ALPHA - MIN_ALPHA)
return {
backgroundColor: `rgba(${HEAT_RGB}, ${alpha.toFixed(2)})`,
isDark: alpha > DARK_TEXT_THRESHOLD,
}
}

5.The legend reuses the exact same color function as the cells

The gradient bar under the table calls getHeatColor(min, min, max) and getHeatColor(max, min, max) — the same function every cell uses — rather than hand-picking two colors that merely look similar. The legend can't drift out of sync with what a shade actually means in the table above it, because it's computed from the same code path.

src/components/heatmap/data-table.tsx
const legendLow = getHeatColor(min, min, max).backgroundColor
const legendHigh = getHeatColor(max, min, max).backgroundColor
// ...
style={{ background: `linear-gradient(to right, ${legendLow}, ${legendHigh})` }}