Installation
pnpm dlx shadcn@latest add https://dithercn.dev/r/heatmap.jsonUsage
import { Heatmap } from "@/components/dithercn/heatmap";
const data = [
{ date: "2026-10-10", value: 3 },
{ date: "2026-10-11", value: 0 },
{ date: "2026-10-12", value: 7 },
];
export function Deploys() {
return <Heatmap data={data} unit={{ one: "deploy", other: "deploys" }} />;
}Each column is a week and each row a weekday. The grid ends with the latest day in data and goes back weeks weeks, 53 by default. Days missing from data count as 0, and entries for the same day add up. Dates are read in UTC, so a square never moves with the visitor's time zone.
Every square is 4×4 dither cells. With the default four steps a square is dotted, a checkerboard, a hollow frame or solid, and an empty day is a faint backing square. Each level is a fixed mask rather than a raw threshold, so every square keeps a crisp outline and the levels stay distinct even at 2px cells. The scale goes up to six steps.
The grid fills its container. Cells shrink from cell down to 2px to fit every week; below that the oldest weeks drop off, so a phone shows the most recent months.
Hover a square, or focus the grid and use the arrow keys, for a readout above it. Hover a level in the scale to spotlight every square at that level, and click to keep it lit.
Examples
A year of activity
A full year with weeks starting on Monday, in color="purple". On a narrow screen it keeps the latest weeks that fit.
Weekday by hour
mode="matrix" takes a grid of values with row and column labels instead of dates. Here pushes by weekday and hour, with cell={4} for larger squares and steps={5}. Column labels thin out when they would collide.
Props
| Prop | Type | Default |
|---|---|---|
| mode A day per square, or any grid of values. | Type: "calendar" | "matrix" | Default "calendar" |
| data* Calendar: values by day, date as "YYYY-MM-DD" or a Date read in UTC. Matrix: data[row][column]. | Type: { date: string | Date; value: number }[] | number[][] | Default – |
| weeks Calendar only: weeks shown, ending with the latest day. | Type: number | Default 53 |
| weekStart Calendar only: the first row, 0 for Sunday or 1 for Monday. | Type: 0 | 1 | Default 0 |
| rows Matrix only, required: row labels, top to bottom. | Type: string[] | Default – |
| columns Matrix only, required: column labels, left to right. | Type: string[] | Default – |
| steps Densities above empty, 2 to 6. The last is solid. | Type: number | Default 4 |
| max The value drawn solid. Larger values draw solid too, so one outlier doesn't wash out the rest. | Type: number | Default The largest value |
| unit What a value counts, for the readout and summary: "min" or { one: "deploy", other: "deploys" }. | Type: string | { one: string; other: string } | Default – |
| color Ink colour, a palette name or any CSS colour. | Type: DitherColor | Default "ink" |
| bloom Soft glow behind the two densest levels. | Type: "off" | "low" | "high" | Default "off" |
| cell Largest dither cell in CSS pixels. Shrinks to 2 to fit the container. | Type: number | Default --dither-cell |
| legend Show the Less to More scale. | Type: boolean | Default true |
| interactive Readout, keyboard and the scale spotlight. false draws a static image. | Type: boolean | Default true |
| animate Squares ripple in from the top left on first view. | Type: boolean | Default true |
Notes
- The grid is a focusable group labelled with a summary: the range, the total and the busiest square. Arrow keys move a square at a time (left and right move a week in calendar mode), Home and End jump to the ends, and a live region reads each square. Each step in the scale is a toggle button with its range as its name.
- The ripple plays once when the grid scrolls into view. With reduced motion the grid appears finished and the readout moves without easing.
- It is one SVG: a backing path and one path per level, built from whole cells snapped to device pixels. Hover only moves an outline and the readout by transform.