Heatmap Chart
Interactive matrix heatmap card.
Matrix
An interactive matrix heatmap.
Active users
1,892
+5.2%Mon
Tue
Wed
Thu
Fri
Sat
Sun
000204060810121416182022LessMore
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.jsonnpx shadcn@latest add https://boardcn.dev/r/heatmap-chart-card.jsonBoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"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.
| Prop | Type | Default | Description |
|---|---|---|---|
| rowsrequired | HeatmapRow[] | — | — |
| activeColor | string | — | Color used by the active cell. Custom colors mix with black when omitted. |
| color | string | — | CSS color used for the cell ramp. Defaults to BoardCN chart tone 6. |
| columnLabelEvery | number | — | Show every nth column label. Auto-thins to at most twelve labels. |
| columns | string[] | — | — |
| defaultRange | string | — | — |
| delta | number | — | Decimal change, e.g. `0.052` renders as `+5.2%`. |
| format | (value: number) => string | (value: number) => value.toLocaleString("en-US") | — |
| headline | number | — | Idle headline. Defaults to the sum of all visible cells. |
| max | number | — | Value at which the ramp reaches full saturation. |
| onRangeChange | (id: string) => void | — | — |
| range | string | — | Static period label used when `ranges` is not supplied. |
| ranges | HeatmapRange[] | — | — |
| title | string | Active users | — |