# Heatmap

A calendar or matrix heatmap built from dither cells, where every square steps cleanly from a few dots to solid.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The dithercn agent skill, for building any dither on the engine, is at [/.well-known/agent-skills/dithercn/SKILL.md](/.well-known/agent-skills/dithercn/SKILL.md).

> **This is an example, not the only option.** It is one component built on the dithercn engine. Install it when it is what you were asked for. For any other look, don't stretch its props to fit: write your own field on `DitherShader` ([Write your own dither](/docs/engine/write-your-own.md)), and read this example's source for technique. Its full source is in [https://dithercn.dev/r/heatmap.json](https://dithercn.dev/r/heatmap.json), under `files[].content`.

## Installation [#installation]

```bash
npx shadcn@latest add https://dithercn.dev/r/heatmap.json
```

## Usage [#usage]

```tsx
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 [#examples]

### A year of activity [#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.

```tsx title="examples/heatmap-year.tsx"
import { Heatmap } from "@/components/dithercn/heatmap";

const END = Date.UTC(2026, 9, 12);
const DAY = 86_400_000;

const noise = (n: number) => {
  const x = Math.sin(n * 78.233 + 12.9898) * 43_758.5453;
  return x - Math.floor(x);
};

// Writing sessions in minutes: a seasonal swell, quiet Sundays, a break in August.
const data = Array.from({ length: 371 }, (_, index) => {
  const time = END - index * DAY;
  const date = new Date(time);
  const season = 0.55 + 0.45 * Math.sin((index / 365) * Math.PI * 2 + 1.2);
  const sunday = date.getUTCDay() === 0 ? 0.25 : 1;
  const holiday = date.getUTCMonth() === 7 && date.getUTCDate() < 15 ? 0 : 1;
  const minutes = Math.round(
    Math.max(0, season * sunday * holiday * 140 * noise(index) - 18)
  );
  return { date: date.toISOString().slice(0, 10), value: minutes };
});

const hours = Math.round(data.reduce((sum, day) => sum + day.value, 0) / 60);

export default function HeatmapYear() {
  return (
    <div className="flex w-full max-w-3xl flex-col gap-5">
      <div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
        <h3 className="text-lg font-semibold tracking-tight">
          {hours.toLocaleString("en")} hours of writing this year
        </h3>
        <span className="text-muted-foreground font-mono text-xs">
          Oct 2025 to Oct 2026
        </span>
      </div>
      <Heatmap data={data} weekStart={1} color="purple" unit="min" />
    </div>
  );
}
```

### Weekday by hour [#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.

```tsx title="examples/heatmap-matrix.tsx"
import { Heatmap } from "@/components/dithercn/heatmap";

const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
const HOURS = Array.from({ length: 24 }, (_, hour) =>
  String(hour).padStart(2, "0")
);

const noise = (n: number) => {
  const x = Math.sin(n * 39.346 + 11.135) * 24_634.6345;
  return x - Math.floor(x);
};

// Pushes to main by weekday and hour: office hours, a dip at lunch, little on weekends.
const data = DAYS.map((_, day) =>
  HOURS.map((__, hour) => {
    const office = hour >= 9 && hour <= 18 ? 1 : 0.12;
    const lunch = hour === 12 || hour === 13 ? 0.55 : 1;
    const weekend = day >= 5 ? 0.18 : 1;
    const friday = day === 4 && hour >= 15 ? 0.4 : 1;
    return Math.round(
      40 *
        office *
        lunch *
        weekend *
        friday *
        (0.6 + noise(day * 24 + hour) * 0.6)
    );
  })
);

export default function HeatmapMatrix() {
  return (
    <div className="bg-background border-border w-full max-w-2xl rounded-2xl border p-5 [--dither-paper:var(--background)] sm:p-6">
      <div className="mb-6 flex flex-col gap-1">
        <span className="font-medium">When the team pushes to main</span>
        <span className="text-muted-foreground text-sm">
          Last 90 days, by weekday and hour (UTC). Fridays go quiet after 3pm.
        </span>
      </div>
      <Heatmap
        mode="matrix"
        data={data}
        rows={DAYS}
        columns={HOURS}
        cell={4}
        steps={5}
        color="orange"
        unit={{ one: "push", other: "pushes" }}
      />
    </div>
  );
}
```

## Props [#props]

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | `"calendar" \| "matrix"` | `"calendar"` | A day per square, or any grid of values. |
| `data` (required) | `{ date: string \| Date; value: number }[] \| number[][]` |  | Calendar: values by day, date as "YYYY-MM-DD" or a Date read in UTC. Matrix: data[row][column]. |
| `weeks` | `number` | `53` | Calendar only: weeks shown, ending with the latest day. |
| `weekStart` | `0 \| 1` | `0` | Calendar only: the first row, 0 for Sunday or 1 for Monday. |
| `rows` | `string[]` |  | Matrix only, required: row labels, top to bottom. |
| `columns` | `string[]` |  | Matrix only, required: column labels, left to right. |
| `steps` | `number` | `4` | Densities above empty, 2 to 6. The last is solid. |
| `max` | `number` | `The largest value` | The value drawn solid. Larger values draw solid too, so one outlier doesn't wash out the rest. |
| `unit` | `string \| { one: string; other: string }` |  | What a value counts, for the readout and summary: "min" or { one: "deploy", other: "deploys" }. |
| `color` | `DitherColor` | `"ink"` | Ink colour, a palette name or any CSS colour. |
| `bloom` | `"off" \| "low" \| "high"` | `"off"` | Soft glow behind the two densest levels. |
| `cell` | `number` | `--dither-cell` | Largest dither cell in CSS pixels. Shrinks to 2 to fit the container. |
| `legend` | `boolean` | `true` | Show the Less to More scale. |
| `interactive` | `boolean` | `true` | Readout, keyboard and the scale spotlight. false draws a static image. |
| `animate` | `boolean` | `true` | Squares ripple in from the top left on first view. |

## Notes [#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.