Skip to content
UI Kit
Accent color

Heatmap

Values as tinted cells in five accent strengths, as a calendar of weeks or a labelled matrix. Arrow keys move through the cells.

One of 7 components in Charts

2 variants

Variants

Calendar

Half a year of days in weeks, months on top, weekdays on the side. On phones the latest weeks stay.

Matrix

Weekdays by opening hours, labels on both axes, one tooltip per cell.

Details

What Heatmap brings along, read from the registry and the source.

Category
Charts
Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/heatmap.tsx
Brings along
utilskitchart
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The HeatmapProps interface from components/ui/heatmap.tsx, with the comments from the code. Also takes every prop of ChartA11y.

  • variantoptional
    'calendar' | 'matrix'

    calendar: weeks by weekdays from dated values. matrix: any rows by columns.

  • daysoptional
    HeatmapDay[]

    calendar: one entry per day. Missing days count as empty.

  • weekStartoptional
    0 | 1

    calendar: first day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English.

  • rowsoptional
    string[]

    matrix: row labels, e.g. weekdays

  • columnsoptional
    string[]

    matrix: column labels, e.g. hours

  • valuesoptional
    number[][]

    matrix: values[row][column]

  • formatoptional
    Intl.NumberFormatOptions

    Value format in tooltip and table

  • maxoptional
    number

    Upper end of the scale. Default: the largest value.

  • rowLabeloptional
    string

    Header of the first table column (matrix), e.g. "Day"

  • legendoptional
    boolean

    Scale from less to more under the grid

Accessibility

Read from the source of the component.

  • Heatmap handles the keys Escape, Home and End itself.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 280 lines
components/ui/heatmap.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, numberFormatter, parseDate, rampColor, textWidth, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';

export interface HeatmapDay {
  /** "2026-09-14" */
  date: string;
  value: number;
}

export interface HeatmapProps extends ChartA11y {
  /** calendar: weeks by weekdays from dated values. matrix: any rows by columns. */
  variant?: 'calendar' | 'matrix';
  /** calendar: one entry per day. Missing days count as empty. */
  days?: HeatmapDay[];
  /** calendar: first day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English. */
  weekStart?: 0 | 1;
  /** matrix: row labels, e.g. weekdays */
  rows?: string[];
  /** matrix: column labels, e.g. hours */
  columns?: string[];
  /** matrix: values[row][column] */
  values?: number[][];
  /** Value format in tooltip and table */
  format?: Intl.NumberFormatOptions;
  /** Upper end of the scale. Default: the largest value. */
  max?: number;
  /** Header of the first table column (matrix), e.g. "Day" */
  rowLabel?: string;
  /** Scale from less to more under the grid */
  legend?: boolean;
  className?: string;
}

const GAP = 3;

/**
 * Values as a grid of tinted cells: a calendar of weeks, or a labelled matrix.
 *   <Heatmap variant="calendar" label="Bookings per day" days={[{ date: '2026-09-14', value: 6 }, …]} />
 *   <Heatmap variant="matrix" label="Guests by hour" rows={['Mon', …]} columns={['8', …]} values={[[…], …]} />
 */
export function Heatmap({ variant = 'calendar', days = [], weekStart, rows = [], columns = [], values = [], format, max, rowLabel, legend = true, label, summary, labels, animate = true, className }: HeatmapProps) {
  const intl = useIntlLocale();
  const t = useTexts(CHART_TEXTS, labels);
  const fmt = numberFormatter(intl, format);
  const [wrapRef, width] = useWidth<HTMLDivElement>(640);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<{ r: number; c: number } | null>(null);
  // The ring and the tooltip keep the last cell while they fade, and glide from cell to cell
  const lastRef = useRef<{ r: number; c: number } | null>(null);
  const wasOpen = useRef(false);
  if (active) lastRef.current = active;
  const jump = !wasOpen.current;
  useEffect(() => {
    wasOpen.current = active !== null;
  });

  // Build one grid for both variants: cells[r][c] with a value (or null), a title and a column label
  type Cell = { v: number | null; title: string } | null;
  let grid: Cell[][] = [];
  let rowNames: string[] = [];
  let colNames: string[] = [];
  let colMarks: (string | null)[] = [];
  let tableHead: string[] = [];
  let tableRows: string[][] = [];

  if (variant === 'calendar') {
    const start = weekStart ?? (intl === 'de-DE' ? 1 : 0);
    const byDate = new Map(days.map((d) => [d.date, d.value]));
    const dates = days.map((d) => parseDate(d.date)).filter((d): d is Date => !!d).sort((a, b) => a.getTime() - b.getTime());
    const first = dates[0] ?? new Date(Date.UTC(2026, 0, 1));
    const last = dates[dates.length - 1] ?? first;
    const offset = (first.getUTCDay() - start + 7) % 7;
    const origin = new Date(first.getTime() - offset * 864e5);
    const weeks = Math.max(1, Math.ceil(((last.getTime() - origin.getTime()) / 864e5 + 1) / 7));
    const long = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'long' });
    const monthF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'short' });
    const dayF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short' });
    // Label Monday, Wednesday and Friday, whatever day the week starts with
    rowNames = Array.from({ length: 7 }, (_, r) => ([1, 3, 5].includes((r + start) % 7) ? dayF.format(new Date(origin.getTime() + r * 864e5)) : ''));
    grid = Array.from({ length: 7 }, (_, r) =>
      Array.from({ length: weeks }, (_, c) => {
        const d = new Date(origin.getTime() + (c * 7 + r) * 864e5);
        if (d < first || d > last) return null;
        const key = d.toISOString().slice(0, 10);
        return { v: byDate.get(key) ?? null, title: long.format(d) };
      }),
    );
    colMarks = Array.from({ length: weeks }, (_, c) => {
      const d = new Date(origin.getTime() + c * 7 * 864e5);
      const top = new Date(Math.max(d.getTime(), first.getTime()));
      return c === 0 || top.getUTCDate() <= 7 ? monthF.format(top) : null;
    });
    colNames = colMarks.map((m) => m ?? '');
    tableHead = ['', label];
    tableRows = days.map((d) => [long.format(parseDate(d.date)!), fmt(d.value)]);
  } else {
    rowNames = rows;
    colNames = columns;
    colMarks = columns;
    grid = rows.map((_, r) => columns.map((col, c) => ({ v: values[r]?.[c] ?? null, title: `${rows[r]}, ${col}` })));
    tableHead = [rowLabel ?? '', ...columns];
    tableRows = rows.map((row, r) => [row, ...columns.map((_, c) => (typeof values[r]?.[c] === 'number' ? fmt(values[r][c]) : t.noData))]);
  }

  const nRows = grid.length;
  const nCols = grid[0]?.length ?? 0;
  const all = grid.flat().map((c) => c?.v ?? 0);
  const top = max ?? Math.max(1, ...all);
  const step = (v: number | null) => (v === null || v <= 0 ? -1 : Math.min(4, Math.max(0, Math.ceil((v / top) * 5) - 1)));

  // Layout
  const left = Math.max(...rowNames.map((s) => textWidth(s, 11)), 0) + (rowNames.some(Boolean) ? 10 : 0);
  const headH = 20;
  let cellW: number;
  let cellH: number;
  let firstCol = 0;
  if (variant === 'calendar') {
    const fit = Math.floor((width - left + GAP) / Math.max(1, nCols)) - GAP;
    cellW = Math.min(26, fit);
    // Too narrow for every week: keep the most recent weeks that fit at 10 px
    if (cellW < 10) {
      cellW = 10;
      firstCol = Math.max(0, nCols - Math.floor((width - left + GAP) / (cellW + GAP)));
    }
    cellH = cellW;
  } else {
    cellW = (width - left + GAP) / Math.max(1, nCols) - GAP;
    cellH = Math.max(18, Math.min(28, round(cellW * 0.7, 0)));
  }
  const visibleCols = nCols - firstCol;
  const height = headH + nRows * (cellH + GAP) - GAP;
  const cx = (c: number) => round(left + (c - firstCol) * (cellW + GAP));
  const cy = (r: number) => round(headH + r * (cellH + GAP));
  const markEvery = variant === 'matrix' ? Math.max(1, Math.ceil((Math.max(...colNames.map((s) => textWidth(s, 11)), 1) + 6) / (cellW + GAP))) : 1;

  // Cells enter as a diagonal wave from the top left corner (kitch-cell below)
  // Cells in reading order of time: column by column (weeks), then row by row
  const ordered = () => {
    const out: { r: number; c: number }[] = [];
    for (let c = firstCol; c < nCols; c++) for (let r = 0; r < nRows; r++) if (grid[r][c]) out.push({ r, c });
    return out;
  };
  const valid = (r: number, c: number) => r >= 0 && r < nRows && c >= firstCol && c < nCols && grid[r][c] !== null;
  const onKeyDown = (e: React.KeyboardEvent) => {
    const cur = active ?? { r: 0, c: firstCol };
    const moves: Record<string, [number, number]> = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] };
    if (e.key === 'Escape') return setActive(null);
    if (e.key === 'Home' || e.key === 'End') {
      e.preventDefault();
      const flat = ordered();
      setActive(e.key === 'Home' ? flat[0] : flat[flat.length - 1]);
      return;
    }
    const m = moves[e.key];
    if (!m) return;
    e.preventDefault();
    if (!active) {
      const firstValid = ordered()[0];
      return setActive(firstValid ?? null);
    }
    let r = cur.r + m[0];
    let c = cur.c + m[1];
    // Skip the gaps at the start and end of a calendar
    while (r >= 0 && r < nRows && c >= firstCol && c < nCols && !valid(r, c)) {
      r += m[0];
      c += m[1];
    }
    if (valid(r, c)) setActive({ r, c });
  };
  const pick = (e: React.PointerEvent<HTMLDivElement>) => {
    const box = e.currentTarget.getBoundingClientRect();
    const px = ((e.clientX - box.left) / (box.width || width)) * width;
    const py = e.clientY - box.top;
    const c = Math.floor((px - left) / (cellW + GAP)) + firstCol;
    const r = Math.floor((py - headH) / (cellH + GAP));
    setActive(valid(r, c) ? { r, c } : null);
  };

  const act = active ? grid[active.r][active.c] : null;
  const shown = lastRef.current && grid[lastRef.current.r]?.[lastRef.current.c] && lastRef.current.c >= firstCol ? lastRef.current : null;
  const shownCell = shown ? grid[shown.r][shown.c] : null;
  const live = act ? `${act.title}, ${act.v === null ? t.noData : fmt(act.v)}` : '';
  const total = all.reduce((a, b) => a + b, 0);
  const peak = grid.flatMap((row) => row).reduce<Cell>((best, cell) => (cell && cell.v !== null && (!best || (best.v ?? -1) < cell.v) ? cell : best), null);
  const auto = peak ? `${t.total} ${fmt(total)}, max ${fmt(peak.v ?? 0)} (${peak.title})` : '';

  return (
    <ChartRoot anim={anim} rootRef={rootRef} className={className}>
      <div ref={wrapRef}>
        <ChartSurface
          label={label}
          summary={summary ?? auto}
          labels={labels}
          live={live}
          table={<ChartTable caption={label} head={tableHead} rows={tableRows} />}
          onKeyDown={onKeyDown}
          onFocus={(e) => {
            if (e.target === e.currentTarget && !active && e.currentTarget.matches(':focus-visible')) {
              const firstValid = ordered()[0];
              setActive(firstValid ?? null);
            }
          }}
          onBlur={() => setActive(null)}
          onPointerMove={pick}
          onPointerDown={pick}
          onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
        >
          <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet" aria-hidden className="block overflow-visible">
            <g className="text-[11px]" fill="var(--muted-foreground)">
              {rowNames.map((s, r) =>
                s ? (
                  <text key={r} x={left - 10} y={cy(r) + cellH / 2} dy="0.34em" textAnchor="end" className={cn(active?.r === r && 'fill-foreground')}>
                    {s}
                  </text>
                ) : null,
              )}
              {colMarks.map((s, c) =>
                s && c >= firstCol && (variant === 'calendar' || (c - firstCol) % markEvery === 0) && !(variant === 'calendar' && c > firstCol && colMarks.slice(Math.max(firstCol, c - 2), c).some(Boolean)) ? (
                  <text key={c} x={variant === 'calendar' ? cx(c) : round(cx(c) + cellW / 2)} y={12} textAnchor={variant === 'calendar' ? 'start' : 'middle'} className={cn(active?.c === c && 'fill-foreground')}>
                    {s}
                  </text>
                ) : null,
              )}
            </g>
            {grid.map((row, r) =>
              row.map((cell, c) => {
                if (!cell || c < firstCol) return null;
                const s = step(cell.v);
                return (
                  <rect
                    key={`${r}-${c}`}
                    x={cx(c)}
                    y={cy(r)}
                    width={round(cellW)}
                    height={round(cellH)}
                    rx={variant === 'calendar' ? 3 : 4}
                    fill={s < 0 ? 'var(--kitch-empty)' : rampColor(s)}
                    className={anim ? 'kitch-cell' : undefined}
                    style={anim ? ({ '--kitch-d': `${round(((c - firstCol + r) / Math.max(1, visibleCols + nRows - 2)) * 640, 0)}ms` } as React.CSSProperties) : undefined}
                  />
                );
              }),
            )}
            {shown && (
              <rect
                x={-0.75}
                y={-0.75}
                width={round(cellW + 1.5)}
                height={round(cellH + 1.5)}
                rx={variant === 'calendar' ? 3.5 : 4.5}
                fill="none"
                stroke="var(--foreground)"
                strokeWidth={1.5}
                className="kitch-glide pointer-events-none"
                data-jump={jump ? '' : undefined}
                style={{ transform: `translate(${cx(shown.c)}px, ${cy(shown.r)}px)`, opacity: active ? 1 : 0 }}
              />
            )}
          </svg>
          {shown && shownCell && (
            <ChartTooltip open={active !== null} jump={jump} x={round(cx(shown.c) + cellW / 2)} y={Math.max(0, Math.min(cy(shown.r) - 6, height - 56))} width={width} offset={cellW / 2 + 8} title={shownCell.title} rows={[{ key: 'v', label, value: shownCell.v === null ? t.noData : fmt(shownCell.v) }]} />
          )}
        </ChartSurface>
      </div>
      {legend && (
        <div className="mt-4 flex items-center justify-end gap-1.5 text-[11px] text-muted-foreground" aria-hidden>
          <span className="me-1">{t.less}</span>
          <span className="size-3 rounded-[3px]" style={{ background: 'var(--kitch-empty)' }} />
          {[0, 1, 2, 3, 4].map((s) => (
            <span key={s} className="size-3 rounded-[3px]" style={{ background: rampColor(s) }} />
          ))}
          <span className="ms-1">{t.more}</span>
        </div>
      )}
    </ChartRoot>
  );
}

Frequent questions

How do I install Heatmap?

With aiw add @ai-website/heatmap. The command puts components/ui/heatmap.tsx into your project, the code is yours from then on. It brings along chart. It needs no further npm packages.

Which variants does Heatmap have?

2 variants, Calendar and Matrix. You pick one with the variant prop, for example variant="matrix".

Can an AI assistant build with Heatmap?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Heatmap with aiw_components and install it with aiw_add @ai-website/heatmap. Name the variant in your prompt, for example variant="calendar", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard