v1.0

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

PropTypeDefaultDescription
title*string—
rows*string[]—
columns*string[]—
values*number[][]—`values[row][column]`.
hintstring—
format(v: number) => stringcompactNumber
cellSizenumber22
classNamestring—