# Heatmap

A heatmap of daily activity over the past year, one square per day.

Source: [heatmap.tsx](https://github.com/jakejarvis/jarvis-ui/blob/main/src/components/ui/heatmap.tsx)

```tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Heatmap, HeatmapLegend, HeatmapTotal } from "@/components/ui/heatmap";
import { dailyCounts } from "@/examples/data";

const deploys = dailyCounts();

function formatDeploys(count: number) {
  return count === 1 ? "1 deploy" : `${count.toLocaleString("en-US")} deploys`;
}

export default function HeatmapDeploys() {
  return (
    <Card className="w-full">
      <CardHeader>
        <CardTitle>Deploys</CardTitle>
      </CardHeader>
      <CardContent>
        <Heatmap data={deploys} endDate="2026-09-30" formatCount={formatDeploys} label="Deploys">
          <HeatmapTotal />
          <HeatmapLegend />
        </Heatmap>
      </CardContent>
    </Card>
  );
}
```

## Installation

Add the registry, once per project:

```bash
npx shadcn@latest registry add @jarvis=https://ui.jarv.is/r/{name}.json
```

Then add the component:

```bash
npx shadcn@latest add @jarvis/heatmap
```

## Usage

```tsx
import { Heatmap, HeatmapLegend, HeatmapTotal } from "@/components/ui/heatmap";

const deploys = [
  { date: "2026-09-14", count: 3 },
  { date: "2026-09-15", count: 7 },
  { date: "2026-09-17", count: 1 },
];

function formatDeploys(count: number) {
  return count === 1 ? "1 deploy" : `${count.toLocaleString("en-US")} deploys`;
}

export function Deploys() {
  return (
    <Heatmap data={deploys} endDate="2026-09-30" formatCount={formatDeploys}>
      <HeatmapTotal />
      <HeatmapLegend />
    </Heatmap>
  );
}
```

Give `data` a count for each day that has one, by ISO date, and `endDate` the last day to show,
usually today. Take today from your data or the server rather than reading the clock while
rendering, since the server's date and the reader's can differ. The heatmap reads every date as UTC, so a day never shifts into the next for
readers in other time zones. Each day takes one of five shades, split at quarters of the busiest day
unless `thresholds` sets them.

`formatCount` words a count for the tooltip, the total and screen readers, and `locale` formats
the dates. Together they put the heatmap in another language, along with `label`, the legend's
`less` and `more`, and a function for `HeatmapTotal`.

The squares grow to fill the width, up to 12 pixels. Where there's no room for them at 9, the heatmap
scrolls, opening on the latest week. Point at a day, or focus the heatmap and move with the arrow keys,
to see its count. Page Up and Page Down move four weeks, and Home and End go along the row.

## Composition

- `Heatmap`
  - `HeatmapTotal`
  - `HeatmapLegend`

## Examples

### Colors

`color` takes any CSS color for the busiest days, and the lighter shades mix it into the empty one.

```tsx
import { Heatmap, HeatmapLegend } from "@/components/ui/heatmap";
import { dailyCounts } from "@/examples/data";

const commits = dailyCounts("2026-09-30", 3);

function formatCommits(count: number) {
  return count === 1 ? "1 commit" : `${count.toLocaleString("en-US")} commits`;
}

export default function HeatmapColors() {
  return (
    <Heatmap
      data={commits}
      endDate="2026-09-30"
      formatCount={formatCommits}
      label="Commits"
      color="var(--chart-2)"
    >
      <HeatmapLegend className="ms-auto" />
    </Heatmap>
  );
}
```

### Weeks

`weeks` sets how many to show and `weekStartsOn={1}` starts them on Monday. Pass `HeatmapTotal`
a function to word the total yourself.

```tsx
import { Heatmap, HeatmapLegend, HeatmapTotal } from "@/components/ui/heatmap";
import { dailyCounts } from "@/examples/data";

const reviews = dailyCounts("2026-09-30", 11);

function formatReviews(count: number) {
  return count === 1 ? "1 review" : `${count.toLocaleString("en-US")} reviews`;
}

export default function HeatmapWeeks() {
  return (
    <Heatmap
      data={reviews}
      endDate="2026-09-30"
      weeks={26}
      weekStartsOn={1}
      thresholds={[1, 3, 6, 9]}
      formatCount={formatReviews}
      label="Code reviews"
    >
      <HeatmapTotal>{(total) => `${formatReviews(total)} since April`}</HeatmapTotal>
      <HeatmapLegend />
    </Heatmap>
  );
}
```

### Selected day

Set `selected` and `onSelectedChange` to pick a day by clicking it, or with Enter or Space. Picking it
again clears it.

```tsx
"use client";

import * as React from "react";

import { Heatmap, HeatmapLegend, HeatmapTotal } from "@/components/ui/heatmap";
import { dailyCounts } from "@/examples/data";

const deploys = dailyCounts();

function formatDeploys(count: number) {
  return count === 1 ? "1 deploy" : `${count.toLocaleString("en-US")} deploys`;
}

export default function HeatmapSelectedDay() {
  const [selected, setSelected] = React.useState<string | null>("2026-09-15");
  const count = deploys.find((day) => day.date === selected)?.count ?? 0;

  return (
    <div className="flex w-full flex-col gap-4">
      <Heatmap
        data={deploys}
        endDate="2026-09-30"
        formatCount={formatDeploys}
        label="Deploys"
        selected={selected}
        onSelectedChange={setSelected}
      >
        <HeatmapTotal />
        <HeatmapLegend />
      </Heatmap>
      <p className="text-sm text-muted-foreground">
        {selected ? (
          <>
            <span className="font-medium text-foreground">
              {new Date(selected).toLocaleDateString("en-US", {
                dateStyle: "long",
                timeZone: "UTC",
              })}
            </span>
            : {formatDeploys(count)}
          </>
        ) : (
          "Select a day to see its deploys."
        )}
      </p>
    </div>
  );
}
```

## Props

| Part | Extends |
| --- | --- |
| `Heatmap` | `<div>` |
| `HeatmapTotal` | `<span>` |
| `HeatmapLegend` | `<div>` |

### Heatmap

| Prop                | Type                               | Description                                                                                                                                                   |
| ------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `color?`            | `string`                           | The color of the busiest days. Any CSS color. Default: `"var(--chart-1)"`                                                                                     |
| `data`              | `HeatmapDay[]`                     | Each day's count, by ISO date such as `2026-09-15`. Days left out count as zero, and repeated dates add up.                                                   |
| `endDate`           | `string`                           | The last day shown, as an ISO date, usually today.                                                                                                            |
| `weeks?`            | `number`                           | How many weeks to show, ending with the week of `endDate`. Default: `53`                                                                                      |
| `weekStartsOn?`     | `0 \| 1`                           | The day each week starts on: 0 for Sunday, 1 for Monday. Default: `0`                                                                                         |
| `thresholds?`       | `[number, number, number, number]` | The lowest count for each of the four shades. Defaults to quarters of the busiest day.                                                                        |
| `formatCount?`      | `((count: number) => string)`      | Words a count, such as `3 deploys`, for the tooltip, the total and screen readers. Defaults to `No events`, `1 event` and so on, with the number in `locale`. |
| `label?`            | `string`                           | Names the heatmap for screen readers. Default: `"Activity"`                                                                                                   |
| `locale?`           | `string`                           | The BCP 47 locale that formats the dates, and the numbers in the default `formatCount`. Default: `"en-US"`                                                    |
| `selected?`         | `string \| null`                   | The selected day, as an ISO date, when it's controlled. Days can only be selected when this, `defaultSelected` or `onSelectedChange` is set.                  |
| `defaultSelected?`  | `string \| null`                   | The day selected at first, as an ISO date, when the selection isn't controlled. Default: `null`                                                               |
| `onSelectedChange?` | `((date: string \| null) => void)` | Called with the day that's clicked, or with `null` when the selected day is clicked again.                                                                    |

### HeatmapTotal

| Prop        | Type                                   | Description                                                                                                                                                                   |
| ----------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `children?` | `((total: number) => React.ReactNode)` | Renders the total, the sum of the counts in the weeks shown, yourself. By default it's the heatmap's `formatCount` followed by `in the last year`, or by the number of weeks. |

### HeatmapLegend

| Prop    | Type              | Description                                   |
| ------- | ----------------- | --------------------------------------------- |
| `less?` | `React.ReactNode` | The word before the shades. Default: `"Less"` |
| `more?` | `React.ReactNode` | The word after the shades. Default: `"More"`  |

The children of `Heatmap` go in a row below the days, lined up with them.
