Heatmap grid
A calendar-style intensity grid in foreground opacities, with a label on every cell.
npx shadcn@latest add https://ui.goodtabs.co/r/heatmap-grid.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/heatmap-grid
Weekdays busy, weekends quiet, with month and weekday labels and a legend.
Compact
Eight weeks in a narrow column. The cells shrink to fit.
Usage
import { HeatmapGrid } from "@/components/ui/heatmap-grid"
<HeatmapGrid title="Commits per day" unit="commits" start="2026-07-20" values={values} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| values | number[] | One number per day, oldest first, a whole number of weeks. | |
| start | string | ISO date of the first value, which should be a Monday. | |
| unit | string | What a value counts, used in each cell's label. | |
| title | string | What the chart shows, used as the group label. |
Source
heatmap-grid/heatmap-grid.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
type HeatmapGridProps = Omit<React.ComponentProps<"figure">, "title"> & {
/** One number per day, oldest first. The length should be a whole number of weeks. */
values: number[];
/** ISO date (YYYY-MM-DD) of the first value. It should be a Monday. */
start: string;
/** What a value counts, for cell labels: "commits", "deploys". */
unit: string;
/** What the chart shows, read by screen readers. */
title: string;
};
/** Foreground opacity per intensity level, from empty to busiest. */
const LEVEL = ["bg-foreground/6", "bg-foreground/20", "bg-foreground/40", "bg-foreground/65", "bg-foreground/90"];
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Zero stays level 0; anything above it lands in 1 to 4 by its share of the maximum. */
function level(value: number, max: number) {
return value <= 0 || max <= 0 ? 0 : Math.min(4, Math.ceil((value / max) * 4));
}
/**
* A calendar heatmap: one column per week, one row per weekday, intensity in foreground opacity.
* Server component. Every cell has its own label with the date and the count.
*/
function HeatmapGrid({ values, start, unit, title, className, ...props }: HeatmapGridProps) {
const weeks = Math.ceil(values.length / 7);
const max = Math.max(...values, 0);
const first = new Date(`${start}T00:00:00Z`);
const dateAt = (i: number) => new Date(first.getTime() + i * 86400000);
// A month label only shows when it has three columns to itself, so neighbours never touch.
const starts = Array.from({ length: weeks }, (_, w) => w).filter(
(w) => w === 0 || dateAt(w * 7).getUTCMonth() !== dateAt((w - 1) * 7).getUTCMonth(),
);
const shown = starts.reduce<number[]>((acc, w) => (w - (acc.at(-1) ?? -3) >= 3 ? [...acc, w] : acc), []);
const months = Array.from({ length: weeks }, (_, w) =>
shown.includes(w) ? MONTHS[dateAt(w * 7).getUTCMonth()] : "",
);
return (
<figure data-slot="heatmap-grid" className={cn("m-0 w-full", className)} {...props}>
<div role="group" aria-label={title} className="flex gap-2">
<div
aria-hidden="true"
className="grid grid-rows-[auto_repeat(7,1fr)] gap-0.75 pt-0 font-mono text-[10px] text-muted-foreground"
>
<span className="h-3.5" />
{DAYS.map((d, i) => (
<span key={d} className="flex items-center leading-none">
{i % 2 === 0 ? d : ""}
</span>
))}
</div>
<div className="min-w-0 flex-1">
<div
aria-hidden="true"
className="mb-1 grid h-3.5 gap-0.75 font-mono text-[10px] leading-none text-muted-foreground"
style={{ gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))` }}
>
{months.map((m, w) => (
<span key={w} className="overflow-visible whitespace-nowrap">
{m}
</span>
))}
</div>
<div
className="grid grid-flow-col grid-rows-7 gap-0.75"
style={{ gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))` }}
>
{values.map((v, i) => {
const d = dateAt(i);
const label = `${DAYS[i % 7]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}: ${v} ${unit}`;
return (
<span
key={i}
role="img"
aria-label={label}
title={label}
className={cn("aspect-square w-full rounded-[2px]", LEVEL[level(v, max)])}
/>
);
})}
</div>
</div>
</div>
<figcaption className="mt-3 flex items-center justify-end gap-1.5 font-mono text-[10px] text-muted-foreground">
<span>Less</span>
{LEVEL.map((l) => (
<span key={l} aria-hidden="true" className={cn("size-2.5 rounded-[2px]", l)} />
))}
<span>More</span>
</figcaption>
</figure>
);
}
export { HeatmapGrid };
For AI agents
Hand your agent this page, the markdown at /components/heatmap-grid/prompt.md, or the Copy AI prompt button above.
When to use
- Activity over weeks: commits, deploys, check-ins
- Profile and project pages that show a rhythm at a glance
- When the pattern of busy and quiet days matters more than exact counts
When not to use
- Exact values or trends: use the area chart
- Fewer than four weeks of data, where a list says more
- Categories without an order, since intensity implies more and less
Agent notes
- Server component, no dependencies. Cells are square and fill the width, so height follows the number of weeks
- Each cell is role img with a label like Mon 3 Nov: 5 commits, and also a title for hover
- Five levels: zero is 6 percent foreground, then 20, 40, 65 and 90. There is no colour scale
- Pad values to full weeks and start on a Monday; the grid fills columns top to bottom, seven rows
The maths
- Level is ceil(value / max × 4) for any value above zero, so the busiest day is always level 4 and a single event is never invisible.
- Opacities 6, 20, 40, 65 and 90 percent step roughly 20 to 25 points apart, so neighbouring levels stay distinguishable in both themes.
- The grid has weeks columns and 7 rows; cell i sits in week floor(i / 7) and row i mod 7, which is what grid-flow-col gives.