Heatmaps

Heatmaps and density charts use color to show how values spread across a grid, a calendar or a plane.

LLMs

Code

Heatmaps are drawn with TanStack Charts, through the kit's Chart components. They paint the chart with the theme's colors, grid and type, and add three palette props. The chart itself is a TanStack definition, written the way TanStack documents it.

Install

npx antuikit@latest init
npx antuikit@latest add tanstack-chart
The first command installs AntuikitProvider and the theme tokens. The second adds the chart components and TanStack Charts itself.

Your first heatmap

A heatmap is a cell mark on two band scales. x and y name the column and the row, and color names the value. The definition's color option turns that value into a color and draws the legend.
import { cell, colorGradientLegend, defineChart } from "@tanstack/charts"
import { scaleBand } from "@tanstack/charts/scales/band"
import { tooltip } from "@tanstack/charts/tooltip"
import { scaleQuantize } from "d3-scale"
import { Chart } from "@/components/ui/tanstack-chart"
const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri"]
const definition = defineChart(
{
marks: [cell(visits, { x: "hour", y: "weekday", color: "visits", inset: 1, radius: 2 })],
scales: {
x: { scale: () => scaleBand<number>().paddingInner(0.06) },
y: { scale: scaleBand<string>().domain(weekdays).paddingInner(0.06) },
},
color: {
scale: scaleQuantize<string>,
range: [
"var(--ant-blue-1)",
"var(--ant-blue-2)",
"var(--ant-blue-3)",
"var(--ant-blue-4)",
"var(--ant-blue-5)",
"var(--ant-blue-6)",
"var(--ant-blue-7)",
],
legend: colorGradientLegend({ label: "Visits" }),
},
},
{
tooltip: {
use: tooltip,
content: ([point]) => ({
title: `${point?.datum.weekday}, ${point?.datum.hour}:00`,
rows: [{ label: "Visits", value: String(point?.datum.visits), color: point?.color }],
}),
},
},
)
export function VisitsHeatmap() {
return (
<Chart
definition={definition}
ariaLabel="Site visits by weekday and hour, last 4 weeks"
height={240}
/>
)
}
The chart fills its container's width, so give it a height. ariaLabel is required. Color scales come from d3-scale: add it with npm install d3-scale and npm install -D @types/d3-scale.

Which component

All three take the same definition and the same palette props.
Chart components

Color scales

Pick the scale by what the value means. Pass a factory, such as scaleQuantize<string>, to let the chart take the domain from the data. Pass an instance to fix it yourself.
  • scaleQuantize splits the range of values into equal steps, one color each. Use it for magnitude.
  • scaleThreshold takes the boundaries between steps in domain, one fewer than the colors. Use it when the steps have meaning, such as a target or a limit.
  • scaleOrdinal maps named levels, such as "No usage" or "Up to 25M", to colors, when you compute the level yourself.
import { scaleThreshold } from "d3-scale"
color: {
scale: scaleThreshold<number, string>,
domain: [5, 12, 24],
range: ["var(--ant-blue-2)", "var(--ant-blue-4)", "var(--ant-blue-6)", "var(--ant-blue-8)"],
}
Build the range from one ramp of the theme, such as var(--ant-blue-1) to var(--ant-blue-7), so it follows the theme and dark mode. For a diverging scale, run two ramps toward a neutral middle, and only when the data has a real midpoint. The Color guideline covers which kind fits which data.

Legends

Set legend on the color option. colorLegend reads the scale and draws exact steps for a quantize or threshold scale. colorGradientLegend draws a smooth ramp from the lowest to the highest value instead, and needs a numeric domain.
import { colorGradientLegend, colorLegend } from "@tanstack/charts"
legend: colorLegend({ label: "Cars" })
legend: colorGradientLegend({ label: "IMDb rating" })
The legend is hidden from screen readers. When the steps carry meaning, also list them in HTML next to the chart, for example with the kit's Badge.

Cell labels and missing cells

Add a text mark on the same x and y to print each value in its cell. Return a light text color from fill on the darkest steps, so the label keeps its contrast.
import { cell, text } from "@tanstack/charts"
marks: [
cell(rated, { x: "episode", y: "season", color: "rating", inset: 1 }),
cell(unrated, {
x: "episode",
y: "season",
fill: "var(--ant-color-fill-secondary)",
inset: 1,
}),
text(episodes, {
x: "episode",
y: "season",
text: (row) => (row.rating === null ? "-" : row.rating.toFixed(1)),
fill: (row) =>
isDark(row.rating) ? "var(--ant-color-text-light-solid)" : chartColors.text,
fontSize: 9,
}),
]
Draw missing cells with their own cell mark and a fixed neutral fill, so they do not take the lowest color.

Binning points and dates

To turn raw points into a grid, count them with binXY and draw each bin with a rect from its edges. Filter out empty bins if zero should show as the background.
import { binXY, rect } from "@tanstack/charts"
const cells = binXY(penguins, {
x: "bill_length",
y: "bill_depth",
xThresholds: [30, 34, 38, 42, 46, 50, 54, 58, 62],
yThresholds: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23],
outputs: { count: { reduce: "count" } },
}).filter((bin) => bin.count > 0)
rect(cells, { x1: "x1", x2: "x2", y1: "y1", y2: "y2", color: "count" })
For a calendar, total the events per day with binTimeX and a d3-time interval, then place each day by its week and weekday.
import { binTimeX } from "@tanstack/charts"
import { utcDay } from "d3-time"
const days = binTimeX(events, {
value: "at",
interval: utcDay,
domain: [start, end],
outputs: { tokens: { value: "tokens", reduce: "sum" } },
})

Hexbins and contours

The spatial marks have their own entry points, so they stay out of your bundle until you import them.
  • hexbin from @tanstack/charts/spatial/hexbin counts raw points into hexagons. binWidth is the distance between centers in pixels, so a hexagon covers a wider range of values on a smaller chart. Both position scales must be continuous, such as linear, not band.
  • densityContour from @tanstack/charts/spatial/density draws lines around dense areas. bandwidth sets the smoothing and thresholds the levels.
  • contour from @tanstack/charts/spatial/contour fills bands from a grid of values, given its width and height in cells.
import { hexbin } from "@tanstack/charts/spatial/hexbin"
hexbin(cars, {
x: "weight",
y: "economy",
binWidth: 24,
color: "count",
stroke: chartColors.surface,
})

Colors

A color scale with its own range keeps those colors, so the palette props only change marks that use the series colors, such as a density contour painted with chartColors.series. Use chartColors for outlines and text on top of the cells. The Color guideline covers which palette fits which data.
hexbin(cars, {
x: "weight",
y: "economy",
color: "count",
// The gap between hexagons takes the chart's background.
stroke: chartColors.surface,
strokeWidth: 0.75,
})
Palette props
Taken by Chart, RendererChart and CanvasChart. Rows marked antuikit in the Version column are added by the kit.

Animation

Use RendererChart with TanStack's motion() renderer to animate marks in and between data updates. It skips the animation when the reader has asked for reduced motion.
import { motion } from "@tanstack/charts/motion"
import { RendererChart } from "@/components/ui/tanstack-chart"
const renderer = motion({ initial: "always" })
<RendererChart definition={definition} renderer={renderer} ariaLabel="..." />

API

The marks, transforms and color options heatmaps use most. TanStack Charts' documentation has the full reference.
cell
color
binXY
hexbin