Heatmap
Preview
Basic
Loading…
Preview
Code
ts
import Heatmap from '@/components/data/Heatmap';src/components/data/Heatmap.tsx
AI prompt
text
Build a heatmap grid component in React + TypeScript + Tailwind CSS (plain divs, no chart library) for magnitude over two categorical axes, such as activity by weekday and hour.
## Look
- Frame: a panel card (`p-5`) with a header holding the title as a 10px uppercase section title plus an ⓘ hint tooltip, and at the right a two-icon chart / table toggle (lucide `BarChart3` / `Table2`).
- Body: a relative, `overflow-x-auto` wrapper around an `inline-grid` with `gap-[2px]` and columns `auto repeat(N, cellSize px)`. The 2px gap IS the grid — no cell borders.
- Labels 10px slate-500 / dark slate-400. A top row of column labels, centred and tabular; with more than 12 columns only every other label is shown so they never collide. Row labels right-aligned with `pr-2`, line-height equal to the cell size.
- Cells: `cellSize` square (default 22px), `rounded-[3px]`.
- Colour: one hue, binned into 8 steps by share of the maximum (`floor(v / max × 8)`, clamped to the last step). Light ramp, near-zero → most: #e8f1fd, #cde2fb, #9ec5f4, #6da7ec, #3987e5, #256abf, #184f95, #0d366b. Dark ramp (runs the other way, so "more" is always more contrast against the surface): #26324a, #184f95, #1c5cab, #256abf, #3987e5, #6da7ec, #9ec5f4, #cde2fb.
- Scale legend under the grid (`mt-3`, 10px slate-500): "Less", the eight swatches as 16×10px `rounded-[2px]` chips, "More", then "peak 1.3K" in tabular-nums.
## Behaviour
- Hovering a cell brightens it (`brightness-110`) and rings it 2px slate-900/60 (dark white/70), and shows a tooltip centred above the cell with a 6px gap: an opaque floating card (`px-2.5 py-1.5 text-xs`, pointer-events none) with the value in semibold tabular-nums then "Tue · 14" (row · column) in slate-500. Leaving the grid clears it.
- Table view: one row per grid row, the row name first and one right-aligned formatted column per grid column.
- A max of 0 → the 240px "No data for the selected period." placeholder. Missing values count as 0. Default format: compact number.
## API
`title`, `hint?`, `rows: string[]`, `columns: string[]`, `values: number[][]` (`values[row][column]`), `format?: (v: number) => string`, `cellSize = 22`, `className?`.
## Accessibility
The grid is `role="img"` with an `aria-label` like "Sessions by weekday and hour: 7 by 24 grid, peak 1.3K. The table view lists every value."; the legend swatches are `aria-hidden`.
## Demo
"Sessions by weekday and hour": rows Mon–Sun, columns 00–23. Weekday working hours 09–18 run ~1,200, the 07–21 shoulders ~45% of that, nights ~8%, weekends ~35% of weekdays, and a lunch dip to 80% at 13:00.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useState } from 'react';
import ChartCard from './ChartCard';
import { compactNumber, useChartPalette } from './chartTheme';
/**
* Magnitude over a grid — activity by weekday and hour, usage by team and
* month. One hue, light (near zero) to dark (most), so "more" is always
* "darker" and there is nothing to decode.
*
* Values are binned into the ramp's steps by their share of the maximum, and
* a scale legend under the grid says so. Each cell has its own hover readout
* (row, column, value) and lifts on hover; the table view carries every
* value for a reader who cannot see the shades apart. A 2px gap in the
* card's colour separates cells — the grid IS the gap, no borders.
*/
export default function Heatmap({
title,
hint,
rows,
columns,
values,
format = compactNumber,
cellSize = 22,
className,
}: {
title: string;
hint?: string;
rows: string[];
columns: string[];
/** `values[row][column]`. */
values: number[][];
format?: (v: number) => string;
cellSize?: number;
className?: string;
}) {
const p = useChartPalette();
const [hover, setHover] = useState<{ r: number; c: number; x: number; y: number } | null>(null);
const max = Math.max(0, ...values.flat());
const steps = p.sequential.length;
const colorOf = (v: number) => (max === 0 ? p.sequential[0] : p.sequential[Math.min(steps - 1, Math.floor((v / max) * steps))]);
return (
<ChartCard
title={title}
hint={hint}
className={className}
empty={max === 0}
table={{
columns: [{ key: 'row', label: '' }, ...columns.map((c) => ({ key: c, label: c, align: 'right' as const, format: (v: unknown) => format(Number(v)) }))],
rows: rows.map((r, ri) => ({ row: r, ...Object.fromEntries(columns.map((c, ci) => [c, values[ri]?.[ci] ?? 0])) })),
}}
>
<div className="relative overflow-x-auto">
<div
role="img"
aria-label={`${title}: ${rows.length} by ${columns.length} grid, peak ${format(max)}. The table view lists every value.`}
className="inline-grid gap-[2px] text-[10px] text-slate-500 dark:text-slate-400"
style={{ gridTemplateColumns: `auto repeat(${columns.length}, ${cellSize}px)` }}
onMouseLeave={() => setHover(null)}
>
<span />
{columns.map((c, ci) => (
<span key={c} className="text-center tabular-nums">
{/* Every other column label, so they never collide. */}
{columns.length > 12 && ci % 2 === 1 ? '' : c}
</span>
))}
{rows.map((r, ri) => (
<div key={r} className="contents">
<span className="pr-2 text-right leading-[22px]" style={{ lineHeight: `${cellSize}px` }}>{r}</span>
{columns.map((c, ci) => {
const v = values[ri]?.[ci] ?? 0;
const on = hover?.r === ri && hover.c === ci;
return (
<span
key={c}
onMouseEnter={(e) => {
const box = e.currentTarget.offsetParent as HTMLElement | null;
const cell = e.currentTarget.getBoundingClientRect();
const origin = box?.getBoundingClientRect();
setHover({ r: ri, c: ci, x: cell.left - (origin?.left ?? 0) + cellSize / 2, y: cell.top - (origin?.top ?? 0) });
}}
style={{ width: cellSize, height: cellSize, background: colorOf(v) }}
className={`rounded-[3px] transition-[filter] ${on ? 'brightness-110 ring-2 ring-slate-900/60 ring-offset-0 dark:ring-white/70' : ''}`}
/>
);
})}
</div>
))}
</div>
{hover && (
<div
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full panel panel-solid px-2.5 py-1.5 text-xs shadow-lg"
style={{ left: hover.x, top: hover.y - 6 }}
>
<span className="font-semibold tabular-nums text-slate-900 dark:text-slate-50">{format(values[hover.r]?.[hover.c] ?? 0)}</span>{' '}
<span className="text-slate-500 dark:text-slate-400">{rows[hover.r]} · {columns[hover.c]}</span>
</div>
)}
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-slate-500 dark:text-slate-400">
Less
{p.sequential.map((c) => (
<span key={c} aria-hidden className="h-2.5 w-4 rounded-[2px]" style={{ background: c }} />
))}
More
<span className="ml-2 tabular-nums">peak {format(max)}</span>
</div>
</div>
</ChartCard>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title* | string | — | |
rows* | string[] | — | |
columns* | string[] | — | |
values* | number[][] | — | `values[row][column]`. |
hint | string | — | |
format | (v: number) => string | compactNumber | |
cellSize | number | 22 | |
className | string | — |