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| Jan | Feb | Mar | Apr | May | Jun | |
|---|---|---|---|---|---|---|
| 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 |
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.
export function getHeatmapRange(rows: HeatmapRow[], columns: string[]) {let min = Infinitylet max = -Infinityfor (const row of rows) {for (const col of columns) {const value = row.values[col]if (value < min) min = valueif (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.
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.
<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.
export function getHeatColor(value: number, min: number, max: number) {const range = max - min || 1const 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.
const legendLow = getHeatColor(min, min, max).backgroundColorconst legendHigh = getHeatColor(max, min, max).backgroundColor// ...style={{ background: `linear-gradient(to right, ${legendLow}, ${legendHigh})` }}