Heatmap Chart

Interactive matrix heatmap card.

Matrix

An interactive matrix heatmap.

Active users

1,892

+5.2%
Mon
Tue
Wed
Thu
Fri
Sat
Sun
000204060810121416182022
LessMore
function HeatmapChartDemo() {
  return (
    <HeatmapChartCard rows={HEATMAP_ROWS} columns={HEATMAP_COLUMNS} ranges={HEATMAP_RANGES} />
  );
}
function HeatmapChartDemo() {
  return (
    <HeatmapChartCard rows={HEATMAP_ROWS} columns={HEATMAP_COLUMNS} ranges={HEATMAP_RANGES} />
  );
}

Installation

npx shadcn@latest add https://boardcn.dev/r/heatmap-chart-card.json
npx shadcn@latest add https://boardcn.dev/r/heatmap-chart-card.json

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/charts/heatmap-chart-card.tsx
"use client";

import { useState, type HTMLAttributes } from "react";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export interface HeatmapRow {
  label: string;
  values: number[];
}

export interface HeatmapRange {
  id: string;
  label: string;
  rows: HeatmapRow[];
  columns?: string[];
  max?: number;
  headline?: number;
  delta?: number;
}

export interface HeatmapChartCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
  title?: string;
  rows: HeatmapRow[];
  columns?: string[];
  /** CSS color used for the cell ramp. Defaults to BoardCN chart tone 6. */
  color?: string;
  /** Color used by the active cell. Custom colors mix with black when omitted. */
  activeColor?: string;
  /** Value at which the ramp reaches full saturation. */
  max?: number;
  /** Idle headline. Defaults to the sum of all visible cells. */
  headline?: number;
  /** Decimal change, e.g. `0.052` renders as `+5.2%`. */
  delta?: number;
  /** Static period label used when `ranges` is not supplied. */
  range?: string;
  ranges?: HeatmapRange[];
  defaultRange?: string;
  onRangeChange?: (id: string) => void;
  format?: (value: number) => string;
  /** Show every nth column label. Auto-thins to at most twelve labels. */
  columnLabelEvery?: number;
}

type ActiveCell = { row: number; col: number } | null;

const LEGEND_STOPS = [0, 0.25, 0.5, 0.75, 1];
const formatNumber = (value: number) => value.toLocaleString("en-US");

function describeDelta(delta: number) {
  const percent = Math.round(Math.abs(delta) * 1000) / 10;
  if (percent === 0) return { label: "0.0%", color: "neutral" as const };
  return {
    label: `${delta > 0 ? "+" : "-"}${percent}%`,
    color: delta > 0 ? ("lime" as const) : ("rose" as const),
  };
}

function useChartRange(
  ranges: HeatmapRange[] | undefined,
  defaultRange: string | undefined,
  onRangeChange: ((id: string) => void) | undefined,
) {
  const [selectedRange, setSelectedRange] = useState(defaultRange);
  const selected = ranges?.find((range) => range.id === selectedRange) ?? ranges?.[0];

  return {
    selected,
    selectedId: selected?.id,
    select(id: string) {
      setSelectedRange(id);
      onRangeChange?.(id);
    },
  };
}

function AnimatedHeadline({ value, format, fadeKey }: { value: number; format: (value: number) => string; fadeKey: string }) {
  const displayedValue = useCountUp(value);
  return (
    <p
      key={fadeKey}
      className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
      aria-live="polite"
    >
      {format(displayedValue)}
    </p>
  );
}

/**
 * BoardCN's matrix heatmap card. The active cell is presented in the headline
 * instead of a floating popover, keeping pointer and keyboard feedback in one
 * stable, responsive location.
 */
export function HeatmapChartCard({
  title = "Active users",
  rows,
  columns,
  color,
  activeColor,
  max,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatNumber,
  columnLabelEvery,
  className,
  ...props
}: HeatmapChartCardProps) {
  const [activeCell, setActiveCell] = useState<ActiveCell>(null);
  const availableRanges = ranges;
  const { selected, selectedId, select } = useChartRange(availableRanges, defaultRange, onRangeChange);

  const visibleRows = selected?.rows ?? rows;
  const visibleColumns =
    selected?.columns ??
    columns ??
    Array.from({ length: visibleRows[0]?.values.length ?? 0 }, (_, index) => String(index));
  const visibleHeadline = selected?.headline ?? headline;
  const visibleDelta = selected?.delta ?? delta;
  const tone = {
    color: color ?? "var(--color-chart-6)",
    activeColor:
      activeColor ??
      (color ? `color-mix(in srgb, ${color} 82%, black)` : "var(--color-chart-6-active)"),
  };
  const values = visibleRows.flatMap((row) => row.values);
  const scaleMaximum = selected?.max ?? max ?? Math.max(1, ...values);
  const total = values.reduce((sum, value) => sum + value, 0);
  const columnCount = visibleColumns.length;
  const labelInterval = columnLabelEvery ?? Math.max(1, Math.ceil(columnCount / 12));
  const isActive = activeCell !== null;
  const activeValue = isActive ? (visibleRows[activeCell.row]?.values[activeCell.col] ?? 0) : 0;
  const activeLabel = isActive
    ? `${visibleRows[activeCell.row]?.label} · ${visibleColumns[activeCell.col]}`
    : title;
  const headlineValue = isActive ? activeValue : (visibleHeadline ?? total);

  function cellColor(value: number, cellTone = tone.color) {
    const percentage = Math.round(8 + 92 * Math.max(0, Math.min(1, value / scaleMaximum)));
    return `color-mix(in srgb, ${cellTone} ${percentage}%, var(--color-chart-track))`;
  }

  return (
    <section
      className={cx(
        "flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        className,
      )}
      {...props}
    >
      <div className="flex w-full items-start justify-between gap-3">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="w-full truncate text-body-medium text-text-secondary">{activeLabel}</p>
          <div className="flex w-full items-center gap-2">
            <AnimatedHeadline
              value={headlineValue}
              format={format}
              fadeKey={`${selectedId ?? ""}:${activeCell ? `${activeCell.row}:${activeCell.col}` : "idle"}`}
            />
            {visibleDelta !== undefined && (
              <Chip
                variant="bold"
                color={describeDelta(visibleDelta).color}
                className={isActive ? "invisible" : undefined}
              >
                {describeDelta(visibleDelta).label}
              </Chip>
            )}
          </div>
        </div>
        {availableRanges && availableRanges.length > 0 ? (
          <ChartRangeControl
            ranges={availableRanges}
            value={selectedId}
            onValueChange={(id) => {
              setActiveCell(null);
              select(id);
            }}
          />
        ) : (
          range && <ChartRangeControl label={range} />
        )}
      </div>

      <div
        className="grid min-h-0 w-full flex-1 gap-1"
        style={{
          gridTemplateColumns: `auto repeat(${columnCount}, minmax(0, 1fr))`,
          gridTemplateRows: `repeat(${visibleRows.length}, minmax(0, 1fr)) auto`,
        }}
        onMouseLeave={() => setActiveCell(null)}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget)) setActiveCell(null);
        }}
      >
        {visibleRows.map((row, rowIndex) => (
          <div className="contents" key={row.label}>
            <span
              className={cx(
                "flex items-center pr-2 text-caption-1-medium whitespace-nowrap transition-colors duration-150 ease-out",
                activeCell?.row === rowIndex ? "text-text-primary" : "text-text-tertiary",
              )}
            >
              {row.label}
            </span>
            {visibleColumns.map((column, columnIndex) => {
              const value = row.values[columnIndex] ?? 0;
              const isCurrent = activeCell?.row === rowIndex && activeCell?.col === columnIndex;
              return (
                <div
                  key={column}
                  role="img"
                  tabIndex={0}
                  aria-label={`${row.label} ${column}: ${format(value)}`}
                  className={cx(
                    "min-h-0 rounded-[4px] outline-none transition-[background-color,box-shadow] duration-150 ease-out",
                    isCurrent && "ring-2 ring-chart-cursor",
                  )}
                  style={{ backgroundColor: cellColor(value, isCurrent ? tone.activeColor : tone.color) }}
                  onMouseEnter={() => setActiveCell({ row: rowIndex, col: columnIndex })}
                  onFocus={() => setActiveCell({ row: rowIndex, col: columnIndex })}
                />
              );
            })}
          </div>
        ))}
        <span aria-hidden />
        {visibleColumns.map((column, columnIndex) => (
          <span
            key={column}
            className={cx(
              "truncate pt-0.5 text-center text-caption-1-medium transition-colors duration-150 ease-out",
              activeCell?.col === columnIndex ? "text-text-primary" : "text-text-tertiary",
              columnIndex % labelInterval !== 0 && "invisible",
            )}
          >
            {column}
          </span>
        ))}
      </div>

      <div className="flex w-full items-center justify-end gap-1.5 pb-1 text-caption-1-medium text-text-tertiary">
        <span>Less</span>
        {LEGEND_STOPS.map((stop) => (
          <span
            key={stop}
            className="size-3 rounded-[3px]"
            style={{ backgroundColor: cellColor(stop * scaleMaximum) }}
            aria-hidden
          />
        ))}
        <span>More</span>
      </div>
    </section>
  );
}

export default HeatmapChartCard;
"use client";

import { useState, type HTMLAttributes } from "react";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export interface HeatmapRow {
  label: string;
  values: number[];
}

export interface HeatmapRange {
  id: string;
  label: string;
  rows: HeatmapRow[];
  columns?: string[];
  max?: number;
  headline?: number;
  delta?: number;
}

export interface HeatmapChartCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
  title?: string;
  rows: HeatmapRow[];
  columns?: string[];
  /** CSS color used for the cell ramp. Defaults to BoardCN chart tone 6. */
  color?: string;
  /** Color used by the active cell. Custom colors mix with black when omitted. */
  activeColor?: string;
  /** Value at which the ramp reaches full saturation. */
  max?: number;
  /** Idle headline. Defaults to the sum of all visible cells. */
  headline?: number;
  /** Decimal change, e.g. `0.052` renders as `+5.2%`. */
  delta?: number;
  /** Static period label used when `ranges` is not supplied. */
  range?: string;
  ranges?: HeatmapRange[];
  defaultRange?: string;
  onRangeChange?: (id: string) => void;
  format?: (value: number) => string;
  /** Show every nth column label. Auto-thins to at most twelve labels. */
  columnLabelEvery?: number;
}

type ActiveCell = { row: number; col: number } | null;

const LEGEND_STOPS = [0, 0.25, 0.5, 0.75, 1];
const formatNumber = (value: number) => value.toLocaleString("en-US");

function describeDelta(delta: number) {
  const percent = Math.round(Math.abs(delta) * 1000) / 10;
  if (percent === 0) return { label: "0.0%", color: "neutral" as const };
  return {
    label: `${delta > 0 ? "+" : "-"}${percent}%`,
    color: delta > 0 ? ("lime" as const) : ("rose" as const),
  };
}

function useChartRange(
  ranges: HeatmapRange[] | undefined,
  defaultRange: string | undefined,
  onRangeChange: ((id: string) => void) | undefined,
) {
  const [selectedRange, setSelectedRange] = useState(defaultRange);
  const selected = ranges?.find((range) => range.id === selectedRange) ?? ranges?.[0];

  return {
    selected,
    selectedId: selected?.id,
    select(id: string) {
      setSelectedRange(id);
      onRangeChange?.(id);
    },
  };
}

function AnimatedHeadline({ value, format, fadeKey }: { value: number; format: (value: number) => string; fadeKey: string }) {
  const displayedValue = useCountUp(value);
  return (
    <p
      key={fadeKey}
      className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
      aria-live="polite"
    >
      {format(displayedValue)}
    </p>
  );
}

/**
 * BoardCN's matrix heatmap card. The active cell is presented in the headline
 * instead of a floating popover, keeping pointer and keyboard feedback in one
 * stable, responsive location.
 */
export function HeatmapChartCard({
  title = "Active users",
  rows,
  columns,
  color,
  activeColor,
  max,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatNumber,
  columnLabelEvery,
  className,
  ...props
}: HeatmapChartCardProps) {
  const [activeCell, setActiveCell] = useState<ActiveCell>(null);
  const availableRanges = ranges;
  const { selected, selectedId, select } = useChartRange(availableRanges, defaultRange, onRangeChange);

  const visibleRows = selected?.rows ?? rows;
  const visibleColumns =
    selected?.columns ??
    columns ??
    Array.from({ length: visibleRows[0]?.values.length ?? 0 }, (_, index) => String(index));
  const visibleHeadline = selected?.headline ?? headline;
  const visibleDelta = selected?.delta ?? delta;
  const tone = {
    color: color ?? "var(--color-chart-6)",
    activeColor:
      activeColor ??
      (color ? `color-mix(in srgb, ${color} 82%, black)` : "var(--color-chart-6-active)"),
  };
  const values = visibleRows.flatMap((row) => row.values);
  const scaleMaximum = selected?.max ?? max ?? Math.max(1, ...values);
  const total = values.reduce((sum, value) => sum + value, 0);
  const columnCount = visibleColumns.length;
  const labelInterval = columnLabelEvery ?? Math.max(1, Math.ceil(columnCount / 12));
  const isActive = activeCell !== null;
  const activeValue = isActive ? (visibleRows[activeCell.row]?.values[activeCell.col] ?? 0) : 0;
  const activeLabel = isActive
    ? `${visibleRows[activeCell.row]?.label} · ${visibleColumns[activeCell.col]}`
    : title;
  const headlineValue = isActive ? activeValue : (visibleHeadline ?? total);

  function cellColor(value: number, cellTone = tone.color) {
    const percentage = Math.round(8 + 92 * Math.max(0, Math.min(1, value / scaleMaximum)));
    return `color-mix(in srgb, ${cellTone} ${percentage}%, var(--color-chart-track))`;
  }

  return (
    <section
      className={cx(
        "flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        className,
      )}
      {...props}
    >
      <div className="flex w-full items-start justify-between gap-3">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="w-full truncate text-body-medium text-text-secondary">{activeLabel}</p>
          <div className="flex w-full items-center gap-2">
            <AnimatedHeadline
              value={headlineValue}
              format={format}
              fadeKey={`${selectedId ?? ""}:${activeCell ? `${activeCell.row}:${activeCell.col}` : "idle"}`}
            />
            {visibleDelta !== undefined && (
              <Chip
                variant="bold"
                color={describeDelta(visibleDelta).color}
                className={isActive ? "invisible" : undefined}
              >
                {describeDelta(visibleDelta).label}
              </Chip>
            )}
          </div>
        </div>
        {availableRanges && availableRanges.length > 0 ? (
          <ChartRangeControl
            ranges={availableRanges}
            value={selectedId}
            onValueChange={(id) => {
              setActiveCell(null);
              select(id);
            }}
          />
        ) : (
          range && <ChartRangeControl label={range} />
        )}
      </div>

      <div
        className="grid min-h-0 w-full flex-1 gap-1"
        style={{
          gridTemplateColumns: `auto repeat(${columnCount}, minmax(0, 1fr))`,
          gridTemplateRows: `repeat(${visibleRows.length}, minmax(0, 1fr)) auto`,
        }}
        onMouseLeave={() => setActiveCell(null)}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget)) setActiveCell(null);
        }}
      >
        {visibleRows.map((row, rowIndex) => (
          <div className="contents" key={row.label}>
            <span
              className={cx(
                "flex items-center pr-2 text-caption-1-medium whitespace-nowrap transition-colors duration-150 ease-out",
                activeCell?.row === rowIndex ? "text-text-primary" : "text-text-tertiary",
              )}
            >
              {row.label}
            </span>
            {visibleColumns.map((column, columnIndex) => {
              const value = row.values[columnIndex] ?? 0;
              const isCurrent = activeCell?.row === rowIndex && activeCell?.col === columnIndex;
              return (
                <div
                  key={column}
                  role="img"
                  tabIndex={0}
                  aria-label={`${row.label} ${column}: ${format(value)}`}
                  className={cx(
                    "min-h-0 rounded-[4px] outline-none transition-[background-color,box-shadow] duration-150 ease-out",
                    isCurrent && "ring-2 ring-chart-cursor",
                  )}
                  style={{ backgroundColor: cellColor(value, isCurrent ? tone.activeColor : tone.color) }}
                  onMouseEnter={() => setActiveCell({ row: rowIndex, col: columnIndex })}
                  onFocus={() => setActiveCell({ row: rowIndex, col: columnIndex })}
                />
              );
            })}
          </div>
        ))}
        <span aria-hidden />
        {visibleColumns.map((column, columnIndex) => (
          <span
            key={column}
            className={cx(
              "truncate pt-0.5 text-center text-caption-1-medium transition-colors duration-150 ease-out",
              activeCell?.col === columnIndex ? "text-text-primary" : "text-text-tertiary",
              columnIndex % labelInterval !== 0 && "invisible",
            )}
          >
            {column}
          </span>
        ))}
      </div>

      <div className="flex w-full items-center justify-end gap-1.5 pb-1 text-caption-1-medium text-text-tertiary">
        <span>Less</span>
        {LEGEND_STOPS.map((stop) => (
          <span
            key={stop}
            className="size-3 rounded-[3px]"
            style={{ backgroundColor: cellColor(stop * scaleMaximum) }}
            aria-hidden
          />
        ))}
        <span>More</span>
      </div>
    </section>
  );
}

export default HeatmapChartCard;

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

HeatmapChartCard

BoardCN's matrix heatmap card. The active cell is presented in the headline instead of a floating popover, keeping pointer and keyboard feedback in one stable, responsive location.

PropTypeDefaultDescription
rowsrequiredHeatmapRow[]
activeColorstringColor used by the active cell. Custom colors mix with black when omitted.
colorstringCSS color used for the cell ramp. Defaults to BoardCN chart tone 6.
columnLabelEverynumberShow every nth column label. Auto-thins to at most twelve labels.
columnsstring[]
defaultRangestring
deltanumberDecimal change, e.g. `0.052` renders as `+5.2%`.
format(value: number) => string(value: number) => value.toLocaleString("en-US")
headlinenumberIdle headline. Defaults to the sum of all visible cells.
maxnumberValue at which the ramp reaches full saturation.
onRangeChange(id: string) => void
rangestringStatic period label used when `ranges` is not supplied.
rangesHeatmapRange[]
titlestringActive users