# Activity heatmap

> Every day as a square, weeks across: one readout glides to the day you point at, and new numbers ripple through week by week.

A calendar of squares shows the rhythm of a thing at a glance (busy weekends, a quiet week, a summer peak), and most versions are a static grid with a tooltip that pops up over the cells. In this one the days fill in as a wave from the oldest week to the newest the first time it’s seen, while the total rolls up. One readout lives on a rail above the grid: it rests on the busiest day, and when you point at a day (or move to it with the arrow keys) it glides to that week, the count rolls and the date morphs, so it never covers the squares. Point at a step of the legend and every day outside that band steps back. New numbers or a new colour ripple across the grid week by week instead of switching at once. It shows as many of the weeks as fit at a readable size, newest on the right.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/activity-heatmap.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Docs and live preview: https://hairlineui.com/ui/activity-heatmap
- Registry item (source): https://hairlineui.com/r/activity-heatmap.json

## Usage

```tsx
import { ActivityHeatmap } from "@/components/activity-heatmap";

<ActivityHeatmap
  data={days} // [{ date: "2026-10-09", value: 12 }, …]
  unit={{ one: "order", other: "orders" }}
/>
```

## Counts per day from your database

Group by day on the server and pass the rows straight in; days with nothing can be left out.

```tsx
import { ActivityHeatmap } from "@/components/activity-heatmap";
import { sql } from "@/lib/db";

export async function OrdersHeatmap() {
  const rows = await sql`
    select to_char(created_at::date, 'YYYY-MM-DD') as date, count(*)::int as value
    from orders
    where created_at > now() - interval '26 weeks'
    group by 1
  `;

  return <ActivityHeatmap title="Orders" data={rows} unit={{ one: "order", other: "orders" }} weeks={26} />;
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `HeatmapDay[]` | – | Days as { date: "YYYY-MM-DD", value }, in any order; missing days are zero. |
| `end` | `string` | `the latest day in data` | The last day shown. |
| `weeks` | `number` | `26` | The most weeks to show; fewer when the card is narrow. |
| `weekStart` | `0 \| 1` | `0` | The first row: 0 for Sunday, 1 for Monday. |
| `unit` | `{ one: string; other: string }` | `{ one: "contribution", other: "contributions" }` | What’s being counted, in the readout and the total. |
| `color` | `string` | `var(--chart-3)` | The colour of the busiest days; the lighter steps are mixed from it. |
| `thresholds` | `[number, number, number, number]` | – | Lower bounds of levels 1 to 4, instead of the data’s quartiles. |
| `title / description / action` | `ReactNode` | – | The header: a title, a line under it and something beside it, such as a metric switch. |
| `locales` | `string \| string[]` | – | For numbers, month and weekday names. |
| `onDayClick` | `(day: HeatmapDay) => void` | – | Called when a day is clicked or Enter is pressed on it. |

## States

- **arrive**: In view for the first time, the days fill in from the oldest week to the newest (520ms each, 16ms per week) and the total rolls up from zero.
- **point**: Pointing at a day rings it and glides the readout to its week (300ms); the count rolls and the date morphs. With nothing pointed at, the readout rests on the busiest day.
- **level**: Pointing at a step of the legend dims every day outside that band to 22%.
- **data**: New values or a new colour change each day’s fill with the same week-by-week delay, so the change ripples across.
- **keyboard**: One tab stop on the grid; arrow keys move a day or a week, Home and End go to the first and last day, Enter calls onDayClick.

## What it handles

Contribution grids are usually a chart library’s calendar or a hand-built table of squares with a hovering tooltip. This takes a list of days and handles the calendar maths, the levels, the readout and the keyboard.

- **Just a list of days**: Pass dates and counts in any order; missing days count as zero and the weeks are built for you.
- **Levels from your data**: Four steps from the data’s own quartiles, or the bounds you pass.
- **A readout that never covers**: One pill on a rail above the grid glides to the day you point at and rests on the busiest one.
- **Read one band**: Point at a step of the legend and only the days in it stay bright.
- **Fits its width**: It shows as many weeks as fit at a readable size, newest on the right.
- **Keyboard and screen readers**: A real grid: one tab stop, arrow keys, Home and End, and each day’s count and date as its label.

## Replaces

a calendar heatmap library, week and weekday maths, quartile levels, a hover tooltip, a legend

## Where it fits

- **Developer and contribution profiles**: Commits, deploys or reviews per day, the way GitHub shows them.
- **Shop and sales dashboards**: Orders or new customers per day, so busy weekdays and quiet weeks stand out.
- **Habit and streak apps**: Days done across months, with the gaps in plain sight.
- **Usage and audit pages**: Logins, API calls or events per day for an account or a team.

## Notes

- Grey in a new shadcn project? Its default theme sets --chart-1 to --chart-5 to greys. Set five colours, light and dark, as in Theming › Chart colours (/ui/theming#charts).
- The grid is an ARIA grid with one tab stop: arrow keys move a day (up and down) or a week (left and right), and each day’s label reads its count and full date.
- The readout pill is aria-hidden; the focused day’s own label carries the same words.
- Dates are read as calendar days in UTC, so a day never shifts with the visitor’s time zone. Month and weekday names wait for hydration before using the visitor’s language.
- Colours mix from one colour (default --chart-3) into your background in four steps; empty days are a light step of your foreground.
- It sizes to its own width: as many weeks as fit at 10px a day or more, up to 16px.
- Installs Number roll and Text morph. With reduced motion the days change in place and the readout jumps.

License: MIT (https://hairlineui.com/license#components)
