Skip to content
goodtabs UI
BrowseBrowse components

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

LessMore

Weekdays busy, weekends quiet, with month and weekday labels and a legend.

Compact

LessMore

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

PropTypeDefaultDescription
valuesnumber[]One number per day, oldest first, a whole number of weeks.
startstringISO date of the first value, which should be a Monday.
unitstringWhat a value counts, used in each cell's label.
titlestringWhat 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.