Agent runs
5,955 runs in the last 12 months
| Oct | Nov | Dec | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| No runs on Sep 21, 2025 | 15 runs on Sep 28, 2025 | No runs on Oct 5, 2025 | 10 runs on Oct 12, 2025 | 5 runs on Oct 19, 2025 | 3 runs on Oct 26, 2025 | No runs on Nov 2, 2025 | 17 runs on Nov 9, 2025 | 5 runs on Nov 16, 2025 | 3 runs on Nov 23, 2025 | No runs on Nov 30, 2025 | 7 runs on Dec 7, 2025 | 1 runs on Dec 14, 2025 | No runs on Dec 21, 2025 | 21 runs on Dec 28, 2025 | 1 runs on Jan 4, 2026 | No runs on Jan 11, 2026 | 6 runs on Jan 18, 2026 | 4 runs on Jan 25, 2026 | 1 runs on Feb 1, 2026 | No runs on Feb 8, 2026 | 7 runs on Feb 15, 2026 | 5 runs on Feb 22, 2026 | 2 runs on Mar 1, 2026 | 1 runs on Mar 8, 2026 | 55 runs on Mar 15, 2026 | 3 runs on Mar 22, 2026 | 1 runs on Mar 29, 2026 | 1 runs on Apr 5, 2026 | 63 runs on Apr 12, 2026 | 6 runs on Apr 19, 2026 | 2 runs on Apr 26, 2026 | No runs on May 3, 2026 | 5 runs on May 10, 2026 | 2 runs on May 17, 2026 | No runs on May 24, 2026 | No runs on May 31, 2026 | 8 runs on Jun 7, 2026 | 2 runs on Jun 14, 2026 | No runs on Jun 21, 2026 | 12 runs on Jun 28, 2026 | 1 runs on Jul 5, 2026 | No runs on Jul 12, 2026 | 14 runs on Jul 19, 2026 | 5 runs on Jul 26, 2026 | 1 runs on Aug 2, 2026 | No runs on Aug 9, 2026 | 93 runs on Aug 16, 2026 | 5 runs on Aug 23, 2026 | 4 runs on Aug 30, 2026 | No runs on Sep 6, 2026 | 101 runs on Sep 13, 2026 | 6 runs on Sep 20, 2026 | |
| Mon | 34 runs on Sep 22, 2025 | 17 runs on Sep 29, 2025 | 35 runs on Oct 6, 2025 | 9 runs on Oct 13, 2025 | 5 runs on Oct 20, 2025 | 1 runs on Oct 27, 2025 | 38 runs on Nov 3, 2025 | 10 runs on Nov 10, 2025 | 2 runs on Nov 17, 2025 | 1 runs on Nov 24, 2025 | 13 runs on Dec 1, 2025 | 4 runs on Dec 8, 2025 | 61 runs on Dec 15, 2025 | 48 runs on Dec 22, 2025 | 25 runs on Dec 29, 2025 | No runs on Jan 5, 2026 | 12 runs on Jan 12, 2026 | 6 runs on Jan 19, 2026 | 4 runs on Jan 26, 2026 | No runs on Feb 2, 2026 | 22 runs on Feb 9, 2026 | 8 runs on Feb 16, 2026 | 5 runs on Feb 23, 2026 | 1 runs on Mar 2, 2026 | 179 runs on Mar 9, 2026 | 11 runs on Mar 16, 2026 | 2 runs on Mar 23, 2026 | 1 runs on Mar 30, 2026 | No runs on Apr 6, 2026 | 13 runs on Apr 13, 2026 | 2 runs on Apr 20, 2026 | No runs on Apr 27, 2026 | 16 runs on May 4, 2026 | 3 runs on May 11, 2026 | No runs on May 18, 2026 | 24 runs on May 25, 2026 | 18 runs on Jun 1, 2026 | 8 runs on Jun 8, 2026 | No runs on Jun 15, 2026 | 27 runs on Jun 22, 2026 | 14 runs on Jun 29, 2026 | 41 runs on Jul 6, 2026 | 32 runs on Jul 13, 2026 | 9 runs on Jul 20, 2026 | 3 runs on Jul 27, 2026 | 45 runs on Aug 3, 2026 | 35 runs on Aug 10, 2026 | 19 runs on Aug 17, 2026 | 3 runs on Aug 24, 2026 | 1 runs on Aug 31, 2026 | 28 runs on Sep 7, 2026 | 21 runs on Sep 14, 2026 | 4 runs on Sep 21, 2026 |
| 10 runs on Sep 23, 2025 | 1 runs on Sep 30, 2025 | 10 runs on Oct 7, 2025 | 1 runs on Oct 14, 2025 | No runs on Oct 21, 2025 | 41 runs on Oct 28, 2025 | 11 runs on Nov 4, 2025 | 1 runs on Nov 11, 2025 | 57 runs on Nov 18, 2025 | 44 runs on Nov 25, 2025 | 1 runs on Dec 2, 2025 | 392 runs on Dec 9, 2025 | 23 runs on Dec 16, 2025 | 16 runs on Dec 23, 2025 | 1 runs on Dec 30, 2025 | 13 runs on Jan 6, 2026 | 4 runs on Jan 13, 2026 | 2 runs on Jan 20, 2026 | No runs on Jan 27, 2026 | 16 runs on Feb 3, 2026 | 4 runs on Feb 10, 2026 | 1 runs on Feb 17, 2026 | No runs on Feb 24, 2026 | 22 runs on Mar 3, 2026 | 7 runs on Mar 10, 2026 | 2 runs on Mar 17, 2026 | 189 runs on Mar 24, 2026 | 25 runs on Mar 31, 2026 | 14 runs on Apr 7, 2026 | 3 runs on Apr 14, 2026 | 216 runs on Apr 21, 2026 | 21 runs on Apr 28, 2026 | 4 runs on May 5, 2026 | No runs on May 12, 2026 | 26 runs on May 19, 2026 | 7 runs on May 26, 2026 | 4 runs on Jun 2, 2026 | 1 runs on Jun 9, 2026 | 29 runs on Jun 16, 2026 | 8 runs on Jun 23, 2026 | 5 runs on Jun 30, 2026 | 16 runs on Jul 7, 2026 | 11 runs on Jul 14, 2026 | 1 runs on Jul 21, 2026 | 289 runs on Jul 28, 2026 | 17 runs on Aug 4, 2026 | 12 runs on Aug 11, 2026 | 4 runs on Aug 18, 2026 | 316 runs on Aug 25, 2026 | 19 runs on Sep 1, 2026 | 8 runs on Sep 8, 2026 | 4 runs on Sep 15, 2026 | 345 runs on Sep 22, 2026 | |
| Wed | 1 runs on Sep 24, 2025 | 5 runs on Oct 1, 2025 | 1 runs on Oct 8, 2025 | 38 runs on Oct 15, 2025 | 29 runs on Oct 22, 2025 | 13 runs on Oct 29, 2025 | 1 runs on Nov 5, 2025 | 42 runs on Nov 12, 2025 | 21 runs on Nov 19, 2025 | 14 runs on Nov 26, 2025 | 48 runs on Dec 3, 2025 | 14 runs on Dec 10, 2025 | 4 runs on Dec 17, 2025 | 2 runs on Dec 24, 2025 | 52 runs on Dec 31, 2025 | 4 runs on Jan 7, 2026 | No runs on Jan 14, 2026 | No runs on Jan 21, 2026 | 16 runs on Jan 28, 2026 | 5 runs on Feb 4, 2026 | No runs on Feb 11, 2026 | 147 runs on Feb 18, 2026 | 19 runs on Feb 25, 2026 | 8 runs on Mar 4, 2026 | 1 runs on Mar 11, 2026 | No runs on Mar 18, 2026 | 13 runs on Mar 25, 2026 | 9 runs on Apr 1, 2026 | 3 runs on Apr 8, 2026 | No runs on Apr 15, 2026 | 15 runs on Apr 22, 2026 | 6 runs on Apr 29, 2026 | No runs on May 6, 2026 | 18 runs on May 13, 2026 | 4 runs on May 20, 2026 | 1 runs on May 27, 2026 | No runs on Jun 3, 2026 | 20 runs on Jun 10, 2026 | 9 runs on Jun 17, 2026 | 1 runs on Jun 24, 2026 | 10 runs on Jul 1, 2026 | 3 runs on Jul 8, 2026 | 1 runs on Jul 15, 2026 | 35 runs on Jul 22, 2026 | 19 runs on Jul 29, 2026 | 3 runs on Aug 5, 2026 | 1 runs on Aug 12, 2026 | No runs on Aug 19, 2026 | 21 runs on Aug 26, 2026 | 4 runs on Sep 2, 2026 | No runs on Sep 9, 2026 | No runs on Sep 16, 2026 | 23 runs on Sep 23, 2026 |
| 41 runs on Sep 25, 2025 | No runs on Oct 2, 2025 | 41 runs on Oct 9, 2025 | 12 runs on Oct 16, 2025 | 7 runs on Oct 23, 2025 | No runs on Oct 30, 2025 | 45 runs on Nov 6, 2025 | 13 runs on Nov 13, 2025 | 8 runs on Nov 20, 2025 | 2 runs on Nov 27, 2025 | 16 runs on Dec 4, 2025 | 2 runs on Dec 11, 2025 | No runs on Dec 18, 2025 | 56 runs on Dec 25, 2025 | 2 runs on Jan 1, 2026 | No runs on Jan 8, 2026 | 15 runs on Jan 15, 2026 | 11 runs on Jan 22, 2026 | 5 runs on Jan 29, 2026 | No runs on Feb 5, 2026 | 18 runs on Feb 12, 2026 | 9 runs on Feb 19, 2026 | 7 runs on Feb 26, 2026 | 1 runs on Mar 5, 2026 | 24 runs on Mar 12, 2026 | 14 runs on Mar 19, 2026 | 3 runs on Mar 26, 2026 | 1 runs on Apr 2, 2026 | No runs on Apr 9, 2026 | 16 runs on Apr 16, 2026 | 3 runs on Apr 23, 2026 | 2 runs on Apr 30, 2026 | 19 runs on May 7, 2026 | 5 runs on May 14, 2026 | No runs on May 21, 2026 | 29 runs on May 28, 2026 | 22 runs on Jun 4, 2026 | 5 runs on Jun 11, 2026 | 1 runs on Jun 18, 2026 | 32 runs on Jun 25, 2026 | 1 runs on Jul 2, 2026 | No runs on Jul 9, 2026 | 38 runs on Jul 16, 2026 | 12 runs on Jul 23, 2026 | 8 runs on Jul 30, 2026 | No runs on Aug 6, 2026 | 42 runs on Aug 13, 2026 | 13 runs on Aug 20, 2026 | 5 runs on Aug 27, 2026 | No runs on Sep 3, 2026 | 45 runs on Sep 10, 2026 | 25 runs on Sep 17, 2026 | 5 runs on Sep 24, 2026 | |
| Fri | 14 runs on Sep 26, 2025 | 29 runs on Oct 3, 2025 | 7 runs on Oct 10, 2025 | 1 runs on Oct 17, 2025 | No runs on Oct 24, 2025 | 32 runs on Oct 31, 2025 | 15 runs on Nov 7, 2025 | 1 runs on Nov 14, 2025 | No runs on Nov 21, 2025 | 52 runs on Nov 28, 2025 | 2 runs on Dec 5, 2025 | 53 runs on Dec 12, 2025 | 29 runs on Dec 19, 2025 | 20 runs on Dec 26, 2025 | No runs on Jan 2, 2026 | 15 runs on Jan 9, 2026 | 5 runs on Jan 16, 2026 | 3 runs on Jan 23, 2026 | No runs on Jan 30, 2026 | 18 runs on Feb 6, 2026 | 6 runs on Feb 13, 2026 | 4 runs on Feb 20, 2026 | 1 runs on Feb 27, 2026 | 26 runs on Mar 6, 2026 | 9 runs on Mar 13, 2026 | 1 runs on Mar 20, 2026 | No runs on Mar 27, 2026 | 29 runs on Apr 3, 2026 | 10 runs on Apr 10, 2026 | 4 runs on Apr 17, 2026 | No runs on Apr 24, 2026 | 13 runs on May 1, 2026 | 5 runs on May 8, 2026 | No runs on May 15, 2026 | 20 runs on May 22, 2026 | 10 runs on May 29, 2026 | 6 runs on Jun 5, 2026 | No runs on Jun 12, 2026 | 34 runs on Jun 19, 2026 | 11 runs on Jun 26, 2026 | 35 runs on Jul 3, 2026 | 27 runs on Jul 10, 2026 | 14 runs on Jul 17, 2026 | 2 runs on Jul 24, 2026 | No runs on Jul 31, 2026 | 21 runs on Aug 7, 2026 | 15 runs on Aug 14, 2026 | 2 runs on Aug 21, 2026 | No runs on Aug 28, 2026 | 24 runs on Sep 4, 2026 | 17 runs on Sep 11, 2026 | 6 runs on Sep 18, 2026 | No runs on Sep 25, 2026 |
| 1 runs on Sep 27, 2025 | 2 runs on Oct 4, 2025 | No runs on Oct 11, 2025 | 14 runs on Oct 18, 2025 | 11 runs on Oct 25, 2025 | 3 runs on Nov 1, 2025 | 1 runs on Nov 8, 2025 | 16 runs on Nov 15, 2025 | 12 runs on Nov 22, 2025 | 6 runs on Nov 29, 2025 | 18 runs on Dec 6, 2025 | 6 runs on Dec 13, 2025 | 4 runs on Dec 20, 2025 | 1 runs on Dec 27, 2025 | 3 runs on Jan 3, 2026 | 1 runs on Jan 10, 2026 | No runs on Jan 17, 2026 | No runs on Jan 24, 2026 | 4 runs on Jan 31, 2026 | 2 runs on Feb 7, 2026 | No runs on Feb 14, 2026 | No runs on Feb 21, 2026 | 7 runs on Feb 28, 2026 | 3 runs on Mar 7, 2026 | No runs on Mar 14, 2026 | 9 runs on Mar 21, 2026 | 5 runs on Mar 28, 2026 | 4 runs on Apr 4, 2026 | 1 runs on Apr 11, 2026 | No runs on Apr 18, 2026 | 6 runs on Apr 25, 2026 | 1 runs on May 2, 2026 | No runs on May 9, 2026 | 7 runs on May 16, 2026 | 2 runs on May 23, 2026 | 1 runs on May 30, 2026 | No runs on Jun 6, 2026 | 8 runs on Jun 13, 2026 | 2 runs on Jun 20, 2026 | No runs on Jun 27, 2026 | 4 runs on Jul 4, 2026 | 3 runs on Jul 11, 2026 | 1 runs on Jul 18, 2026 | 13 runs on Jul 25, 2026 | 4 runs on Aug 1, 2026 | 2 runs on Aug 8, 2026 | 1 runs on Aug 15, 2026 | 15 runs on Aug 22, 2026 | 8 runs on Aug 29, 2026 | 2 runs on Sep 5, 2026 | 1 runs on Sep 12, 2026 | No runs on Sep 19, 2026 | 9 runs on Sep 26, 2026 | |
"use client";
import { useState } from "react";Installation
$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/activity-heatmap.json
Usage
import { ActivityHeatmap } from "@/components/ui/activity-heatmap";
export function AgentActivity() {
return <ActivityHeatmap className="text-chart-2" data={days} />;
}days is one entry per day you have something to say about:
const days = [
{ date: "2025-03-03", value: 14 },
{ date: "2025-03-04", value: 41 },
{ date: "2025-03-05", value: 0 },
];Days with no entry are drawn as empty squares rather than skipped, so a gap in the data
looks like a gap. Pass Date objects or calendar-day strings; either way the grid shows
the day you meant.
Data and dates
A contribution graph is about calendar days, not instants, and the two are easy to
confuse. new Date("2025-03-04") is UTC midnight — which is March 3rd for anyone west of
Greenwich, and a heatmap that is one day off is worse than no heatmap. So a plain
YYYY-MM-DD string is read as a local calendar day, an ISO string with a time in it is
parsed normally, and everything is then flattened to local midnight. Day arithmetic never
touches epoch milliseconds, so nothing slips on a DST boundary either.
The grid ends on a real day — today unless end says otherwise — and starts on the first
day of a week, so a year of history comes out as 53 columns rather than 52.1. Data past
end extends the range instead of being hidden, because a component that silently drops
rows is a component you stop trusting.
Levels
Five of them: empty, then four shades. The cut points between the shades are quantiles of the non-empty counts — the top quarter of days, the top half, and so on — with level 4 pinned to the 95th percentile rather than the maximum, so a dark square keeps meaning "that day was unusual" instead of "that was a Tuesday".
Quantiles are what GitHub uses and they are the right default for activity data, which is skewed: a handful of burst days would flatten a linear scale into one shade. Their cost is real, though — a level describes a day relative to the others, so the same count can change colour when the dataset does. When a level has to keep its meaning, pass the cut points:
// Fixed meaning: 1–4 runs is light, 40+ is the darkest.
<ActivityHeatmap data={days} thresholds={[4, 10, 20, 40]} />Cut points are read from the whole dataset rather than the visible range, so narrowing the window rearranges the grid without repainting what is left of it.
When the level is known before the component sees it, put it on the datum and the scale is skipped:
{ date: "2025-03-04", value: 41, level: 4 }Ties land on the lower level, so a plateau of identical days reads as one shade instead of splitting across two.
Range and geometry
| Prop | Default | Does |
|---|---|---|
weeks | 53 | Columns of history; 53 is a year and a day |
weekStartsOn | 0 | Which day a column starts on, 0 Sunday to 6 Saturday |
end | today | The last day on the grid; data past it extends the range |
cellSize | 11 | Side of a square, in px |
gap | 3 | Space between squares, in px — and around the grid |
53 columns is a year plus a day on purpose: it puts a given weekday in the same row as it was a year ago, which is what makes the vertical bands of a weekly rhythm line up.
Anything older than the first column is simply outside the grid — the window is weeks
wide, not "everything you passed". Narrowing weeks is how a range switch is built.
The grid is wider than a phone, so it scrolls inside its own container with the page body left alone. Everything on it is sized in px rather than in a fluid unit — squares that resize with the viewport stop being a calendar and start being a chart.
Summarising is the caller's job. The component draws the grid; the line above it —
"4,102 runs in the last 12 months" — belongs to whoever knows what a run is. If the
summary and the grid have to agree, derive both from the same weeks the way the demo
does.
Colour
The scale is one colour at five alphas rather than five hand-picked hexes: it is one decision instead of four, it holds on both themes, and it leaves the hue to the caller — so a token class at the call site is the whole palette.
<ActivityHeatmap className="text-chart-2" data={days} />text-chart-2, text-primary, text-muted-foreground — anything that sets a text colour
works, because the ramp is built from currentColor. Leaving className off is not an
unset colour either: the root carries text-foreground, so the default is a neutral grey
scale that reads as "no opinion" beside whatever accent the page already uses. className
is merged rather than concatenated, so the caller's token wins over that default without an
override prop.
Three layers decide what a square is painted with, each overriding the one under it:
| Layer | Set with | Use it for |
|---|---|---|
--heatmap-0 … -4 | style | a curated ramp, one level at a time |
--heatmap-tint | style | the whole ramp in one line, e.g. var(--chart-3) |
currentColor | className | the everyday case — any text-* token |
The middle layer is the one that matters most in practice, because a chart palette is already a sequence:
// The whole scale, from one token, without touching the five levels.
<ActivityHeatmap data={days} style={{ "--heatmap-tint": "var(--chart-2)" }} />The alphas are 12% for an empty day and 26 / 46 / 70 / 100% for levels 1–4. Level 0 is deliberately much lower than the next step: an empty square should read as empty, not as the first shade of busy.
Alpha rather than a fixed ramp is the one decision here worth defending. --chart-1 …
--chart-5 are defined once and used in both themes, and in this theme they run from
blue-300 to blue-800 — so mapping level 1 to --chart-1 and level 4 to --chart-4
would make the busiest days the darkest squares on a dark background, and the scale would
read backwards. An alpha ramp cannot invert: it always moves away from whatever surface it
is on, in either theme, with no second set of values to maintain.
When a curated ramp is what you want, define it per theme — the values below look right on
light and would need a .dark counterpart that moves the other way:
<ActivityHeatmap
data={days}
style={{
"--heatmap-0": "oklch(0.97 0 0)",
"--heatmap-1": "oklch(0.87 0.08 150)",
"--heatmap-2": "oklch(0.75 0.16 150)",
"--heatmap-3": "oklch(0.6 0.16 150)",
"--heatmap-4": "oklch(0.45 0.13 150)",
}}
/>Anything unset falls back down the chain, so setting one level does not cost the others.
Tooltip and locale
| Prop | Default | Does |
|---|---|---|
formatValue | count, or "No activity" | The count in the tooltip |
formatDate | Mar 4, 2025 | The date in the tooltip |
locale | en-US | Month, weekday and date names |
legend | true | The Less/More ramp under the grid |
<ActivityHeatmap
data={days}
formatValue={(value) => `${value} tool calls`}
formatDate={(date) => date.toDateString()}
/>There is one tooltip for the whole grid, not one per square. It is moved and retyped
through the DOM on hover, which is why a 371-square grid does not re-render when the
pointer crosses it — the same string is already on the square as the label a screen reader
reads. It is positioned fixed so that it escapes the grid's own scroll container; an
absolutely positioned bubble would be clipped at the container's edge, which is exactly
where a tooltip is most likely to want to go. It hides on scroll, on leaving the grid, and
on hovering a day that has not happened yet.
locale is fixed rather than read from the browser so that the server and the client
render the same string — hydration does not care which language you prefer, only that both
sides agree. Pass Intl's locale string for the one you want.
Reduced motion
On mount the grid fills in column by column, left to right: a year of squares appearing at once reads as texture, and appearing as a wave reads as a year accumulating, which is the same story the numbers tell. It is a CSS animation, one per square, staggered 12ms a column and capped at 44 columns, so a longer history does not turn the tail into a pause.
With prefers-reduced-motion the animation is dropped and the grid is simply there. New
squares that appear later — a live dataset gaining a day — animate on their own, because
the wave is attached to the element rather than run by the component.
Accessibility
The grid is a real <table>: a column per week, a row per weekday, a <th scope="row"> on
each weekday, a <th scope="colgroup"> per month. Table navigation is how a screen reader
moves through it cell by cell, and each square carries its own label — "41 runs on Mar 4,
2025" — so a level never has to be inferred from a colour. A sr-only <caption> names
the whole thing: the range it covers and the total it adds up to.
Squares are not individually focusable. 371 tab stops is a worse experience than none, and the content is reachable without them, so the grid is left out of the tab order entirely and the tooltip stays a pointer affordance.
Component source
"use client";
import { useRef } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { cn } from "@/lib/utils";
/* -- A year, as a grid ---------------------------------------------------------
* One square per day, one column per week: the shape that daily counts of an agent
* actually have — quiet weekends, a busy Tuesday, a run of dark squares in the week
* something shipped. A sparkline shows the trend of one number; this shows the
* *texture* of a habit, which is the thing you look at when deciding whether last
* month was normal.
*
* The component is a table, a colour scale and one tooltip. Almost everything that
* looks like a decision below is really about one of three things: what a level means,
* where the days come from, and how little work a hover is allowed to cost.
* --------------------------------------------------------------------------- */
/** A day's count. `level` short-circuits the scale when the caller already knows the
* bucket — for a fixed meaning that must not move when the data does. */
export interface HeatmapDatum {
date: string | Date;
value: number;
level?: 0 | 1 | 2 | 3 | 4;
}
export interface ActivityHeatmapProps {
/** One entry per day. Days with no entry are drawn empty rather than skipped. */
data: HeatmapDatum[];
/** Side of a square, in px. The gap is its own prop, not a ratio of this one. */
cellSize?: number;
className?: string;
/** The last day on the grid. Defaults to today; data past it extends the range. */
end?: string | Date;
/** Space between squares, in px — and around the grid, since it is table spacing. */
gap?: number;
/** The tooltip's date. Defaults to `Mar 4, 2025`. */
formatDate?: (date: Date) => string;
/** The tooltip's count. Defaults to `12`, or `No activity` at zero. */
formatValue?: (value: number) => string;
/** Whether the Less/More ramp is drawn under the grid. */
legend?: boolean;
lessLabel?: string;
/** Where month, weekday and date names are read from. Fixed rather than taken from
* the browser so the server and the client render the same string. */
locale?: string;
moreLabel?: string;
style?: CSSProperties;
/** The four cut points between levels 1–4. Pass them when a level has to keep its
* meaning across datasets; left out, they are quartiles of the data. */
thresholds?: [number, number, number, number];
/** Which day a column starts on, `0` Sunday to `6` Saturday. */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** How many columns of history. 53 weeks is a year and a day, so a given weekday
* lands in the same row it was in a year ago. */
weeks?: number;
}
/** The default span: 53 columns, which covers a full year whatever day it ends on. */
const DEFAULT_WEEKS = 53;
/** Square side and spacing. 11 + 3 is the density GitHub settled on — big enough to
* hit with a pointer, small enough that a year fits a column without scrolling. */
const DEFAULT_CELL = 11;
const DEFAULT_GAP = 3;
/* -- The scale -----------------------------------------------------------------
* Five steps: empty, then four shades of one colour. The shades are the colour at
* 26/46/70/100% alpha rather than four hand-picked hexes, because that is one decision
* instead of four, it holds on both themes, and it leaves the hue to the token the
* caller already has — so `text-chart-2` at the call site is the entire colour story.
*
* Level 0 gets a much lower share of its own: an empty day is a square you can see is
* empty, not a sixth colour. GitHub's ramp flattens toward nothing at both ends of its
* scale for the same reason.
* --------------------------------------------------------------------------- */
const LEVEL_SHARE = [12, 26, 46, 70, 100];
/** Where the default cut points fall among the non-empty counts, read as quantiles.
* The top one is not a quartile: level 4 is meant to be rare, so that a dark square
* still reads as "that day was unusual" rather than "that was a Tuesday". */
const QUANTILES = [0.25, 0.5, 0.75, 0.95];
/** Weekday rows that get a printed name, as `Date.getDay()` values. Naming every row
* would be a wall of text beside a grid small enough to read at a glance; Mon, Wed
* and Fri are enough to place any row. */
const LABELLED_WEEKDAYS = new Set([1, 3, 5]);
/** A month narrower than this many columns goes unlabelled — three letters need three
* squares of room, and a label that overhangs its own month is worse than none. The
* span is kept either way, so the next month still starts where it should. */
const MIN_MONTH_COLUMNS = 3;
/* -- The reveal -----------------------------------------------------------------
* On mount the grid fills in column by column, left to right. This is the one piece of
* motion here and it earns its place: a year of squares appearing all at once reads as
* texture, and appearing as a wave reads as a year *accumulating*, which is the same
* story the numbers tell. 12ms a column is a wave you notice at 53 columns and never
* wait on; the cap keeps a longer history from turning the tail into a pause.
* --------------------------------------------------------------------------- */
const REVEAL = "heatmap-cell-in";
const REVEAL_STEP = 12;
const REVEAL_CAP = 44;
/* -- Calendar maths -------------------------------------------------------------
* All of it in local time, on purpose. A contribution graph is about calendar days,
* not instants, and `new Date("2025-03-04")` is UTC midnight — which is March 3rd for
* anyone west of Greenwich. The arithmetic below never touches epoch milliseconds, so
* it also cannot slip an hour on a DST boundary.
* --------------------------------------------------------------------------- */
const pad = (value: number) => String(value).padStart(2, "0");
const dayKey = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate());
const addDays = (date: Date, days: number) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
/** Where a day sits in its week, counted from `weekStartsOn`. */
const dayIndex = (date: Date, weekStartsOn: number) =>
(date.getDay() - weekStartsOn + 7) % 7;
/** A `YYYY-MM-DD` string is a calendar day and is read as one. Anything else is handed
* to `Date` and then flattened to the day it lands on locally. */
const toDay = (value: string | Date) => {
if (value instanceof Date) {
return startOfDay(value);
}
const calendar = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
if (calendar) {
return new Date(
Number(calendar[1]),
Number(calendar[2]) - 1,
Number(calendar[3])
);
}
return startOfDay(new Date(value));
};
/** Nearest-rank quantiles of the counts — the same reading of "the top quarter of
* days" the levels are named after, and cheap enough to redo on every render. */
const quantiles = (values: number[], cuts: number[]) => {
const sorted = values.toSorted((a, b) => a - b);
return cuts.map((cut) => sorted[Math.floor((sorted.length - 1) * cut)] ?? 0);
};
/** How many cut points a count clears. Ties land on the lower level, so a plateau of
* identical days stays one shade instead of splitting across two. */
const levelOf = (value: number, thresholds: number[]) => {
if (value <= 0) {
return 0;
}
return Math.min(
4,
1 + thresholds.filter((threshold) => value > threshold).length
);
};
/** The fill for a level, in three layers, each overriding the one under it: a
* per-level variable, then the shared tint, then `currentColor`. The tint is a
* variable of its own so that swapping the whole ramp for a token is one line —
* `--heatmap-tint: var(--chart-3)` — without giving up the plain `text-chart-2`
* route. */
const cellFill = (level: number) =>
`var(--heatmap-${level}, color-mix(in oklab, var(--heatmap-tint, currentColor) ${LEVEL_SHARE[level]}%, transparent))`;
/** The days, keyed for lookup. Built once per render rather than searched per square:
* 371 squares times a linear scan is the kind of thing that only shows up on someone
* else's laptop. */
const indexByDay = (data: HeatmapDatum[]) => {
const byDay = new Map<string, HeatmapDatum>();
for (const datum of data) {
byDay.set(dayKey(toDay(datum.date)), datum);
}
return byDay;
};
/** The window on screen: the last day, the first day of its week, and every column
* between the two.
*
* The grid ends on a real day — today unless told otherwise — and starts on the first
* day of a week, so the columns are weeks and the rows are weekdays. A year of history
* therefore comes out as 53 columns rather than 52.1. Data past `end` extends the
* range instead of being hidden: a component that silently drops rows is one you stop
* trusting. */
const rangeOf = ({
data,
end,
weekStartsOn,
weeks,
}: {
data: HeatmapDatum[];
end: string | Date | undefined;
weekStartsOn: number;
weeks: number;
}) => {
const requestedEnd = end === undefined ? startOfDay(new Date()) : toDay(end);
let latest: Date | null = null;
for (const datum of data) {
const day = toDay(datum.date);
if (latest === null || day > latest) {
latest = day;
}
}
const endDay =
latest !== null && latest > requestedEnd ? latest : requestedEnd;
const startDay = addDays(
endDay,
-(dayIndex(endDay, weekStartsOn) + (weeks - 1) * 7)
);
return {
columns: Array.from({ length: weeks }, (_, column) =>
addDays(startDay, column * 7)
),
endDay,
startDay,
};
};
/** The month labels, and which column each one starts at.
*
* A label belongs to the column where the month turns over and to no other: it is the
* boundary that is worth printing, not the month's own first column. Every band keeps
* its width whether or not it keeps its label, so a month too narrow to name does not
* pull the next label out of alignment with its own squares. */
const monthBands = (columns: Date[], format: Intl.DateTimeFormat) => {
const starts: { column: number; label: string }[] = [];
let running = columns[0]?.getMonth() ?? -1;
for (let column = 1; column < columns.length; column += 1) {
const week = columns[column];
if (week.getMonth() !== running) {
starts.push({ column, label: format.format(week) });
running = week.getMonth();
}
}
return {
bands: starts.map((start, index) => ({
...start,
span: (starts[index + 1]?.column ?? columns.length) - start.column,
})),
leading: starts[0]?.column ?? columns.length,
};
};
/** Everything the caption adds up, so the summary and the squares agree. */
const sumInRange = (data: HeatmapDatum[], start: Date, end: Date) => {
let total = 0;
for (const datum of data) {
const day = toDay(datum.date);
if (day >= start && day <= end) {
total += datum.value;
}
}
return total;
};
/* -- The tooltip -----------------------------------------------------------------
* One bubble for the whole grid, moved and retyped directly through the DOM. A React
* state per hover would re-render 371 squares to move one box, and a tooltip component
* per square would mount 371 of them; the text it needs is already on the square as
* `data-tooltip`, because the same string is what a screen reader reads.
*
* It is `fixed` rather than absolute so that it escapes the horizontal scroll container
* the grid lives in — an absolutely positioned bubble gets clipped at the container's
* edge, which is exactly where a tooltip is most likely to want to go.
* --------------------------------------------------------------------------- */
const useHoverTooltip = () => {
const bubble = useRef<HTMLDivElement>(null);
const shown = useRef<HTMLElement | null>(null);
const hide = () => {
shown.current = null;
if (bubble.current) {
bubble.current.dataset.visible = "false";
}
};
const show = (cell: HTMLElement) => {
const node = bubble.current;
const text = cell.dataset.tooltip;
// Re-measuring the same bubble for every pixel of travel inside one square is work
// that cannot change the answer.
if (!(node && text) || shown.current === cell) {
return;
}
shown.current = cell;
node.textContent = text;
node.dataset.visible = "true";
const rect = cell.getBoundingClientRect();
const half = node.offsetWidth / 2;
const centre = rect.left + rect.width / 2;
// Kept inside the viewport, or a square in the first or last column would push its
// own label off-screen.
node.style.left = `${Math.min(
Math.max(centre, half + 8),
window.innerWidth - half - 8
)}px`;
node.style.top = `${rect.top - 6}px`;
};
const onPointerOver = (event: ReactPointerEvent<HTMLTableElement>) => {
const cell = (event.target as HTMLElement).closest<HTMLElement>(
"[data-tooltip]"
);
if (cell) {
show(cell);
} else {
// A day that has not happened yet has no text, and no tooltip should linger.
hide();
}
};
return { bubble, hide, onPointerOver };
};
/**
* A year of daily counts as a GitHub-style grid: a column per week, a row per weekday,
* one square per day, shaded by how much happened.
*
* The colour is a text colour: the default ink is `text-foreground`, and a token class at
* the call site overrides it — `text-chart-2`, `text-primary`, anything. For a ramp of its
* own, set `--heatmap-tint`, or `--heatmap-0` … `--heatmap-4` for a level at a time.
*
* Hovering a square raises one shared tooltip; nothing about hover lives in React
* state, which is what keeps a 371-square grid from re-rendering on every pointer move.
*/
export const ActivityHeatmap = ({
cellSize = DEFAULT_CELL,
className,
data,
end,
formatDate,
formatValue,
gap = DEFAULT_GAP,
legend = true,
lessLabel = "Less",
locale = "en-US",
moreLabel = "More",
style,
thresholds,
weekStartsOn = 0,
weeks = DEFAULT_WEEKS,
}: ActivityHeatmapProps) => {
const { bubble, hide, onPointerOver } = useHoverTooltip();
const byDay = indexByDay(data);
const { columns, endDay, startDay } = rangeOf({
data,
end,
weekStartsOn,
weeks,
});
/* Cut points come from the whole dataset rather than the visible range, so that
* shortening the range rearranges the grid without repainting what is left of it. */
const scale =
thresholds ??
quantiles(
data.map((datum) => datum.value).filter((value) => value > 0),
QUANTILES
);
const monthFormat = new Intl.DateTimeFormat(locale, { month: "short" });
const weekdayFormat = new Intl.DateTimeFormat(locale, { weekday: "short" });
const dayFormat = new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
year: "numeric",
});
const formatDay = formatDate ?? ((day: Date) => dayFormat.format(day));
const formatCount =
formatValue ??
((value: number) => (value <= 0 ? "No activity" : String(value)));
const { bands, leading } = monthBands(columns, monthFormat);
/** The row names, read off the first column so they follow `weekStartsOn`. */
const rowLabels = Array.from({ length: 7 }, (_, row) => {
const day = addDays(startDay, row);
return LABELLED_WEEKDAYS.has(day.getDay())
? weekdayFormat.format(day)
: null;
});
const total = sumInRange(data, startDay, endDay);
return (
<div
className={cn("w-full text-foreground", className)}
data-slot="activity-heatmap"
style={style}
>
{/* Keyframes have to travel with the component: a registry install copies files,
not a stylesheet. The same rules from two instances on one page are the same
rule twice, so repeating them costs nothing. */}
<style>{`@keyframes ${REVEAL}{from{opacity:0;transform:scale(0.72)}}
@media (prefers-reduced-motion: reduce){[data-heatmap-cell]{animation:none !important}}`}</style>
<div className="overflow-x-auto" onScroll={hide}>
<table
className="border-separate"
onPointerLeave={hide}
onPointerOver={onPointerOver}
style={{ borderSpacing: gap }}
>
<caption className="sr-only">
{`Daily activity from ${formatDay(startDay)} to ${formatDay(
endDay
)}, ${total} in total.`}
</caption>
<thead>
<tr>
{/* The corner above the weekday names, and the columns before the first
month boundary — the grid starts mid-month most of the time. */}
<th scope="col" />
{leading > 0 ? <th colSpan={leading} scope="colgroup" /> : null}
{bands.map((band) => (
<th
className="pb-1 text-left align-bottom font-normal text-muted-foreground text-xs leading-none whitespace-nowrap"
colSpan={band.span}
key={band.column}
scope="colgroup"
>
{band.span >= MIN_MONTH_COLUMNS ? band.label : null}
</th>
))}
</tr>
</thead>
<tbody>
{rowLabels.map((label, row) => (
<tr key={row}>
<th
className="pr-2 text-right align-middle font-normal text-[10px] text-muted-foreground leading-none whitespace-nowrap"
scope="row"
style={{ height: cellSize }}
>
{label}
</th>
{columns.map((week, column) => {
const day = addDays(week, row);
const datum = byDay.get(dayKey(day));
// Days past the end are the tail of the last week: kept so the last
// column is a column, drawn so it reads as "not yet".
const future = day > endDay;
const value = datum?.value ?? 0;
const level = future
? 0
: (datum?.level ?? levelOf(value, scale));
const text = future
? null
: `${formatCount(value)} on ${formatDay(day)}`;
return (
<td
aria-hidden={future ? true : undefined}
className="rounded-[2px] p-0"
data-heatmap-cell=""
data-tooltip={text ?? undefined}
key={dayKey(day)}
style={{
animation: future
? undefined
: `${REVEAL} 320ms cubic-bezier(0.22, 1, 0.36, 1) ${
Math.min(column, REVEAL_CAP) * REVEAL_STEP
}ms backwards`,
backgroundColor: future ? undefined : cellFill(level),
height: cellSize,
width: cellSize,
}}
>
{text ? <span className="sr-only">{text}</span> : null}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{legend ? (
<div className="mt-3 flex items-center justify-end gap-1.5 text-muted-foreground text-xs">
<span>{lessLabel}</span>
{[0, 1, 2, 3, 4].map((level) => (
<span
aria-hidden="true"
className="rounded-[2px]"
key={level}
style={{
backgroundColor: cellFill(level),
height: cellSize,
width: cellSize,
}}
/>
))}
<span>{moreLabel}</span>
</div>
) : null}
<div
aria-hidden="true"
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full rounded-md bg-primary px-2 py-1 font-medium text-primary-foreground text-xs whitespace-nowrap opacity-0 shadow-md transition-opacity duration-150 data-[visible=true]:opacity-100"
data-visible="false"
ref={bubble}
/>
</div>
);
};