Charts

Calendar Heatmap

A year of days as one week-column grid — quantile or linear colour steps with the bounds printed on the legend, measured zero kept apart from no data, and every tile reachable by keyboard.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, CalendarRange } from "lucide-react"
import { cn } from "@/lib/utils"
import type {
  CalendarHeatmapDay,
  CalendarHeatmapScale,
  ChartCalendarHeatmapData,
} from "./chart-calendar-heatmap.contract"

export interface ChartCalendarHeatmapProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartCalendarHeatmapData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-calendar-heatmap.json

Prompt

Build a React + TypeScript + Tailwind "Calendar Heatmap" component (zod, lucide-react, a cn() class merger).
It draws a window of calendar days as a grid of week columns x 7 weekday rows, coloured by a stepped scale.

Contract
- zod schema, one source of truth:
  status: "loading" | "empty" | "error" | "ready"
  range: { start: string; end: string }            // ISO YYYY-MM-DD, inclusive, read as UTC
  days:  { date: string; value: number | null }[]  // long form, sparse, any order
  scale: {
    binning: "linear" | "quantile"                 // a claim about the distribution, not a style
    domain?: [number, number]                      // linear only; omit to fit to the data
    steps?: number                                 // 2-7, default 5
    unit?: string                                  // legend / readout / labels, never inside a tile
  }
- Props on top of the contract: title, description, today (ISO day), weekStartsOn (0 | 1, default 0),
  cellSize (px 8-28, default 13), cellGap (px 1-8, defaults to cellSize/5), locale (default "en-US"),
  decimals (0-6), selectableSteps (default true), skeletonWeeks (4-60), onRetry, emptyState,
  className, ...div props. forwardRef onto the card.
- `range` is a DECISION, not a consequence of the data: pass a full year and a dead December shows up
  as four empty week columns instead of vanishing, which is the fact this chart exists to show.

Layout maths — export these as pure functions so a test can print the same numbers the picture is
made of. No DOM, no clock, no randomness inside them:
  parseIsoDay(iso) -> whole days since the epoch, or null for anything that is not a real calendar
    day ("2026-02-30", "14/01/2026", ""). Round-trip the parse; do not trust a bare Date.parse.
  weekdayRow(day, weekStartsOn) -> 0..6.   (1970-01-01 was a Thursday: weekday = (day + 4) mod 7.)
  buildCalendarPositions(startDay, endDay, weekStartsOn) -> { offset, date, column, row }[]
    column = floor((firstRow + offset) / 7), row = (firstRow + offset) % 7. The first column is
    PADDED, never rotated: a window opening on a Wednesday keeps Wednesday on the Wednesday row.
  buildMonthRuns(positions, locale) -> { key, label, column, span }[]  — label at the run's first
    column, dropped when the run is a sliver (span < 3 columns) or sits within 3 columns of the
    previous label; January and the first drawn month carry the year.
  ingestCalendarDays(days, startDay, endDay) -> { values, measured, dropped, droppedTotal }
  buildLevelLadder(values, scale) -> { bounds, steps, binning, fixed }   // bounds.length === steps+1
  levelOf(value, ladder) -> step index, or -1 for a day with no measurement
  levelMix(level, steps) -> the colour-mix percentage for that step

Behavior
- The scale is the whole component. Get these five right:
  1. quantile cuts equal-COUNT steps from this data's own distribution; linear cuts equal-WIDTH steps
     across the domain. Daily counts are long-tailed, so linear on a year with four 30-commit release
     days drops 361 days onto the bottom two steps and reports that nothing happened. Offer both and
     print the resulting bounds either way, so the choice is never made off-screen.
  2. Ties collapse. If the data cannot supply `steps + 1` DISTINCT edges (300 zero-days leave no room
     between step 0 and step 1) the ladder shrinks instead of painting identical values differently.
     All values identical -> one step drawn mid-ramp: nothing here is strongest, so nothing looks it.
  3. A fixed `domain` clamps outliers onto the end steps instead of stretching the ramp; that is the
     point of pinning it, and it is what makes this half-year comparable with the next one.
  4. null is not 0. A measured zero lands on the bottom step; a null day gets no fill, a hatch, and
     "no data — not 0" wording in its label. A day absent from `days` renders identically.
  5. Negative values are fine on a sequential ramp — the floor is just negative. Do not silently
     recentre on 0; that is a different chart (diverging), and this one does not claim to be it.
- Nothing is dropped in silence. Count every refused entry by reason — unreadable date, non-numeric
  value, outside the window, repeated day (last entry kept) — and print the totals in a visible note
  under the grid. A grid that quietly discards a tenth of its input lies confidently.
- `today` is an INJECTED ISO day, never `new Date()`: a clock read during render desyncs the server
  from the first client render and makes the component untestable. It draws a ring and moves the
  grid's initial tab stop onto that day when it falls inside the window.
- Keyboard is a first-class path, never a fallback. The grid is ONE tab stop with a roving tabindex:
  Up/Down walk one DAY (across the column boundary, because the calendar is chronological),
  Left/Right walk one WEEK keeping the weekday, PageUp/PageDown jump four weeks, Home/End go to the
  first/last week of that weekday row, Ctrl/Cmd+Home/End to the ends of the window. Everything clamps,
  nothing wraps. Hover and focus feed the same readout line.
- selectableSteps: each legend step is a toggle button that isolates that step across the whole grid —
  matching days keep an inner ring (a shape channel, not just colour), the rest dim. A step with zero
  days gets aria-disabled plus a handler guard, NEVER the native `disabled` attribute, which would
  drop it out of the tab order and take with it the fact that the step exists and is empty. The
  isolation result is announced in a polite live region, because it is the one change focus does not
  announce by itself.
- Degenerate input must not break the geometry: an unreadable window (render the zero state naming the
  two strings), end before start (one day), a window longer than ~5 years (truncate and say so), a
  single measured day, zero measured days (zero state, with the drop note still visible).
- Four states are first-class branches. loading renders a skeleton of week columns plus one sr-only
  status; empty and error centre a message; error shows retry only when onRetry exists.

Rendering & styling
- Semantic tokens only. Tiles are `color-mix(in oklab, var(--chart-2) X%, var(--card))` with X ramping
  10% -> 78% across the steps; mixing into the surface makes the ramp travel AWAY from it in both
  themes, so the grid never inverts its meaning in dark mode. Missing days carry a hatch built from
  `--muted-foreground`; the today ring and the isolation ring are `var(--foreground)` box-shadows,
  composed in ONE style object because two Tailwind shadow classes would fight over the property.
- Colour is never the only channel: the legend prints each step's lower bound, every tile carries a
  native `title` and an `aria-label` with the full date and the exact value, a readout line under the
  grid follows hover and focus, month and weekday labels are direct text, and missing days differ by
  pattern rather than by shade.
- role="grid" on the strip with aria-labelledby on the heading and aria-describedby on a visually
  hidden one-sentence summary (days measured, window, value span, step count, which domain is in
  force). One `role="row"` per weekday with a `role="rowheader"`; padding days are empty gridcells.
  Weekday labels alternate (Mon / Wed / Fri) visually but every rowheader keeps its full name.
- Axis-ish text is text-muted-foreground text-xs, borders use border tokens, the card is
  rounded-xl border bg-card. The month band is aria-hidden — every tile already says its own date.
- One delegated listener per event on the grid (hover / focus / keydown) instead of 365 closures, and
  memoised rows keyed on the two props that move (this row's tab stop, the isolated step), so pointing
  at a tile re-renders the readout line rather than the year.
- prefers-reduced-motion: the only animations are the skeleton pulse (motion-reduce:animate-none) and
  the isolation fade (motion-safe:transition-opacity). With animation off the chart is complete.

Customization levers
- Colour: swap `--chart-2` for any single token; raise the 78% ceiling for a heavier grid or lower the
  10% floor for a lighter one. Keep the ramp mixing into `--card`, not into transparent, or the tiles
  stop being legible on one of the two themes.
- Density: cellSize + cellGap are the two knobs. 10-11px with a 1px gap is a full year in a card;
  16-20px with a 3px gap is a quarter you can point at on a projector.
- Steps: 3 steps read as low/medium/high, 7 as a gradient. Above 5 the legend needs the bounds it
  already prints, and quantile steps stop being distinguishable at a 10px tile.
- Shape: weekStartsOn flips the row order for European weeks; transpose the rows and columns for a
  vertical month-per-column layout by swapping `column` and `row` in the position builder.
- Trim: selectableSteps={false} for a static report (legend keeps its bounds, grid keeps its keyboard);
  drop the readout line if every tile prints its own number; replace the zero branch with `emptyState`.
- Emphasis: add a gamma to levelMix (t ** 0.7) to spread the low end, or pass log-space values with a
  log-space domain. Both belong to the scale, not to the geometry.

Concepts

  • The window is a decisionrange is handed in, not inferred from the data. That is what makes an empty December show up as four blank week columns instead of quietly disappearing, and it is why two calendars of the same length stay the same width.
  • Padded, not rotated — a window that opens on a Wednesday still puts Wednesday on the Wednesday row; the first column simply starts three tiles down. Rotating the rows to save a few pixels would make every weekday reading wrong.
  • Missing is not zeronull means nobody measured, 0 means the measurement was zero. A closed shop and a dead-quiet shop are different facts, so they get different marks: a hatch with no fill against the bottom step of the ramp.
  • Quantile vs linear — quantile cuts steps that hold roughly equal numbers of days, linear cuts steps of equal width. Daily counts are long-tailed, so linear hands the whole ramp to four release days and reports a year in which nothing happened. Both print their bounds, so the reader can always see which cut is in force.
  • Ties collapse the ladder — five steps need five distinct edges. When 300 zero-days leave no room between step 0 and step 1, the ladder shrinks rather than paint two identical values two different colours; all-identical data settles on one mid-ramp step, because nothing there is strongest.
  • The day walk — the grid is one tab stop with a roving tabindex: up and down move a day, left and right move a week keeping the weekday, and isolating a legend step is a button, not a hover. Every pointer path has a keyboard twin, and every refused entry has a number.

On This Page