{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "calendar-heatmap",
  "title": "Calendar Heatmap",
  "description": "A year of daily counts as a grid of shaded squares — the GitHub-style contribution graph, drawn for whatever your app counts per day: commits, deploys, orders, sign-ins, posts, workouts, lessons, support tickets, API calls, or a habit tracker's streak. Pass data as [{ date: '2026-08-10', count: 12 }] and it renders 53 columns of 7 squares with month and weekday headers; sparse data is fine, since a day with no row is drawn as a day with nothing, and two rows for the same day are summed rather than one silently winning. Shading is by quartile of the days that had any activity, so a single 500-commit day does not flatten the rest of the year into one pale block the way scaling against the maximum does — pass thresholds to cut the levels yourself. Dates are held as integers, days since the epoch in UTC, and never as Date objects: new Date('2026-08-10').getDay() is parsed as UTC midnight and answers with the previous day anywhere west of Greenwich, which silently rotates the whole grid by one row, and it is the single most common defect in a hand-rolled contribution graph. Nothing reads the clock either — the window is anchored to the last date in your data, not to Date.now(), so the server and the browser always render the same markup. It is a real table with month columns, weekday row headers and a screen-reader name on every square ('12 commits on Monday, August 10, 2026'), so the year is readable to somebody who cannot tell the four shades apart; conveying a value by colour alone fails WCAG 1.4.1, and the colour key is hidden from assistive technology instead of being announced as five unlabelled swatches. The grid scrolls horizontally on a narrow screen and takes keyboard focus, because a scrollable region that cannot be reached by keyboard puts a year of data out of reach. Shades are one opacity ramp of your theme's primary token, so it follows light and dark without a palette of its own. No dependencies and no hooks, so it renders inside a React server component with no 'use client' of its own and ships no client JavaScript. Official shadcn/ui has nothing that draws this: calendar is a react-day-picker date picker for choosing a day (it pulls in date-fns and the button component), and chart is a Recharts wrapper — neither plots a value per calendar day.",
  "files": [
    {
      "path": "registry/ui/calendar-heatmap.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface CalendarHeatmapDay {\n  /** Calendar day as YYYY-MM-DD. Any other shape, and any impossible date, is ignored. */\n  date: string\n  /** How much happened that day. Missing, negative and non-finite values count as none. */\n  count?: number\n}\n\n/** One rendered square. Passed to `formatLabel` so a caller can write its own description. */\nexport interface CalendarHeatmapCell {\n  /** YYYY-MM-DD. */\n  date: string\n  /** Total for the day — duplicate rows in `data` are summed. */\n  count: number\n  /** 0 for a day with nothing, then 1–4 by intensity. */\n  level: number\n  year: number\n  /** 0–11, so it indexes `monthLabels` directly. */\n  month: number\n  /** Day of the month, 1–31. */\n  day: number\n  /** 0 = Sunday, matching the default order of `weekdayLabels`. */\n  weekday: number\n}\n\ninterface CalendarHeatmapProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Days to plot. Sparse is fine — a day with no row is drawn as a day with nothing. */\n  data: CalendarHeatmapDay[]\n  /** First day to draw, YYYY-MM-DD. Defaults to 364 days before `end`, i.e. a trailing year. */\n  start?: string\n  /** Last day to draw, YYYY-MM-DD. Defaults to the latest date in `data`. */\n  end?: string\n  /** Day each column starts on: 0 = Sunday (default), 1 = Monday. */\n  weekStart?: number\n  /** Three ascending cut points for levels 1–4. Defaults to quartiles of the days that have any. */\n  thresholds?: number[]\n  /** Square size in pixels (default 11). The gap between squares scales with it. */\n  cellSize?: number\n  /** Noun for the accessible description, used verbatim: \"12 commits on …\" (default \"activity\"). */\n  unit?: string\n  /** Draw the Less–More colour key (default true). It is decorative; every square is labelled. */\n  showLegend?: boolean\n  /** Text of that key (default [\"Less\", \"More\"]). */\n  legendLabels?: [string, string]\n  /** Month names, January first. The header shows the first three characters of each. */\n  monthLabels?: string[]\n  /** Weekday names, Sunday first. The row headers show the first three characters of each. */\n  weekdayLabels?: string[]\n  /** Overrides the accessible description of a square — for other languages or plural rules. */\n  formatLabel?: (cell: CalendarHeatmapCell) => string\n  /** Describes the whole grid to a screen reader. */\n  caption?: string\n}\n\nconst MS_PER_DAY = 86400000\n\nconst MONTHS = [\n  \"January\",\n  \"February\",\n  \"March\",\n  \"April\",\n  \"May\",\n  \"June\",\n  \"July\",\n  \"August\",\n  \"September\",\n  \"October\",\n  \"November\",\n  \"December\",\n]\n\nconst WEEKDAYS = [\n  \"Sunday\",\n  \"Monday\",\n  \"Tuesday\",\n  \"Wednesday\",\n  \"Thursday\",\n  \"Friday\",\n  \"Saturday\",\n]\n\n/**\n * Level 0 is `muted` rather than transparent so the grid keeps its shape on any background, and\n * 1–4 are one opacity ramp of `primary` — the intensity *is* the meaning here, so a ramp of the\n * theme's own colour says it without inventing a palette the theme has not agreed to. The hairline\n * ring keeps level 1 distinguishable from level 0 in themes where a light primary at 25% lands\n * close to muted.\n */\nconst LEVEL_CLASS = [\n  \"bg-muted\",\n  \"bg-primary/25\",\n  \"bg-primary/50\",\n  \"bg-primary/75\",\n  \"bg-primary\",\n]\n\n/**\n * Days are held as integers — days since 1970-01-01 UTC — and never as `Date` objects, because\n * every interesting operation here (which weekday, which column, how many days between) is\n * integer arithmetic that a local-time `Date` gets wrong. `new Date(\"2026-08-10\")` is parsed as\n * UTC midnight, so `.getDay()` west of Greenwich reports the day before and the whole grid shifts\n * by one column. Nothing below reads a local-time field, and nothing reads the clock, so the\n * server and the browser render identical markup.\n */\nfunction toEpochDay(iso: string) {\n  const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(String(iso))\n  if (!m) return null\n  const year = Number(m[1])\n  const month = Number(m[2])\n  const day = Number(m[3])\n  const ms = Date.UTC(year, month - 1, day)\n  const back = new Date(ms)\n  // Date.UTC rolls nonsense forward — Feb 30 becomes March 2, and a two-digit year becomes 19xx.\n  // Comparing the round trip is what turns those back into \"not a date\" instead of a silent shift.\n  // Year and month settle it between them: with the day pinned to two digits by the pattern above,\n  // an out-of-range day always rolls into a different month (0 goes back a month, 32+ goes on to\n  // the next), so a third comparison against getUTCDate can never decide a case these two do not.\n  if (back.getUTCFullYear() !== year || back.getUTCMonth() !== month - 1) return null\n  return Math.floor(ms / MS_PER_DAY)\n}\n\nfunction fromEpochDay(epochDay: number) {\n  const d = new Date(epochDay * MS_PER_DAY)\n  return {\n    year: d.getUTCFullYear(),\n    month: d.getUTCMonth(),\n    day: d.getUTCDate(),\n  }\n}\n\nconst pad = (n: number, width: number) => String(n).padStart(width, \"0\")\n\nfunction toIso(epochDay: number) {\n  const { year, month, day } = fromEpochDay(epochDay)\n  return `${pad(year, 4)}-${pad(month + 1, 2)}-${pad(day, 2)}`\n}\n\n/** 1970-01-01 was a Thursday, so +4 rotates the epoch onto a Sunday-indexed week. */\nconst weekdayOf = (epochDay: number) => (((epochDay + 4) % 7) + 7) % 7\n\n/** How far into its column a day sits, once the column is allowed to start on any weekday. */\nconst rowOf = (epochDay: number, weekStart: number) =>\n  (((weekdayOf(epochDay) - weekStart) % 7) + 7) % 7\n\nconst isCount = (n: unknown): n is number =>\n  typeof n === \"number\" && Number.isFinite(n) && n > 0\n\n/**\n * Quartiles by nearest rank over the days that have anything, which is what keeps a single busy\n * day from flattening the rest: scaling linearly against the maximum puts everything below a\n * one-off spike into level 1. Ties collapse the cut points on purpose — a month where every day\n * is a 1 is one flat colour rather than a gradient invented out of nothing.\n */\nfunction quartiles(counts: number[]) {\n  const sorted = [...counts].sort((a, b) => a - b)\n  const n = sorted.length\n  if (!n) return []\n  return [1, 2, 3].map((k) => sorted[Math.min(n - 1, Math.ceil((k * n) / 4) - 1)])\n}\n\nconst levelOf = (count: number, thresholds: number[]) =>\n  count > 0 ? 1 + thresholds.filter((t) => count > t).length : 0\n\n/** Runs of columns belonging to the same month, which is what the header spans. */\nfunction monthRuns(columnStarts: number[], firstDay: number) {\n  const runs: { month: number; span: number }[] = []\n  for (const columnStart of columnStarts) {\n    // Label a column by the month its first *in-range* day falls in. Using the column's own start\n    // would label the leading column by the previous month whenever the range opens mid-week.\n    const { month } = fromEpochDay(Math.max(columnStart, firstDay))\n    const last = runs[runs.length - 1]\n    if (last && last.month === month) last.span += 1\n    else runs.push({ month, span: 1 })\n  }\n  return runs\n}\n\nexport const CalendarHeatmap = React.forwardRef<\n  HTMLDivElement,\n  CalendarHeatmapProps\n>(function CalendarHeatmap(\n  {\n    className,\n    data,\n    start,\n    end,\n    weekStart = 0,\n    thresholds,\n    cellSize = 11,\n    unit = \"activity\",\n    showLegend = true,\n    legendLabels = [\"Less\", \"More\"],\n    monthLabels = MONTHS,\n    weekdayLabels = WEEKDAYS,\n    formatLabel,\n    caption = \"Activity by day\",\n    ...props\n  },\n  ref\n) {\n  // `data` arrives from a JS call site where the prop types no longer hold, so every row is\n  // re-checked rather than trusted. Duplicates are summed: two rows for one day is what a caller\n  // gets from grouping by hour, or from concatenating two sources, and dropping one of them would\n  // silently under-report.\n  const totals = new Map<number, number>()\n  for (const row of Array.isArray(data) ? data : []) {\n    const epochDay = row ? toEpochDay(row.date) : null\n    if (epochDay === null) continue\n    const count = isCount(row.count) ? row.count : 0\n    totals.set(epochDay, (totals.get(epochDay) ?? 0) + count)\n  }\n\n  const explicitEnd = end === undefined ? null : toEpochDay(end)\n  // Folded rather than spread into Math.max: `Math.max(...keys)` passes one argument per day, and\n  // a caller plotting several years of per-hour rows would hand it enough of them to overflow the\n  // call stack — a crash on the size of the input, not on anything wrong with it.\n  let latest: number | null = null\n  for (const epochDay of totals.keys()) {\n    if (latest === null || epochDay > latest) latest = epochDay\n  }\n  const lastDay = explicitEnd ?? latest\n  const explicitStart = start === undefined ? null : toEpochDay(start)\n  // 364 days before the end is 52 whole weeks, so the default window is the 53 columns a trailing\n  // year needs. The window is anchored to the data rather than to the clock: reading `Date.now()`\n  // would make the same props render differently on the server and in the browser across midnight.\n  const firstDay = explicitStart ?? (lastDay === null ? null : lastDay - 364)\n\n  if (firstDay === null || lastDay === null) return null\n  // A reversed range is a caller mistake with an obvious intent, so it is read the way round it\n  // was meant rather than rendered as nothing.\n  const from = Math.min(firstDay, lastDay)\n  const to = Math.max(firstDay, lastDay)\n\n  // Any weekday index is meaningful once folded into 0–6, so a 7 or a -1 is honoured rather than\n  // rejected; a NaN has no meaning to fold, and left alone it would poison every column start and\n  // silently render seven empty rows.\n  const rowStart = Number.isFinite(weekStart)\n    ? ((Math.trunc(weekStart) % 7) + 7) % 7\n    : 0\n\n  const inRange: number[] = []\n  for (const [epochDay, count] of totals) {\n    if (epochDay >= from && epochDay <= to && count > 0) inRange.push(count)\n  }\n  const cuts = (\n    Array.isArray(thresholds)\n      ? thresholds.filter(\n          (t): t is number => typeof t === \"number\" && Number.isFinite(t)\n        )\n      : quartiles(inRange)\n  )\n    .slice(0, 3)\n    .sort((a, b) => a - b)\n\n  const columnStarts: number[] = []\n  for (\n    let columnStart = from - rowOf(from, rowStart);\n    columnStart <= to;\n    columnStart += 7\n  ) {\n    columnStarts.push(columnStart)\n  }\n\n  const cellFor = (epochDay: number): CalendarHeatmapCell => {\n    const count = totals.get(epochDay) ?? 0\n    const { year, month, day } = fromEpochDay(epochDay)\n    return {\n      date: toIso(epochDay),\n      count,\n      level: levelOf(count, cuts),\n      year,\n      month,\n      day,\n      weekday: weekdayOf(epochDay),\n    }\n  }\n\n  const describe = (cell: CalendarHeatmapCell) => {\n    if (formatLabel) return formatLabel(cell)\n    const month = monthLabels[cell.month] ?? MONTHS[cell.month]\n    const weekday = weekdayLabels[cell.weekday] ?? WEEKDAYS[cell.weekday]\n    const when = `${weekday}, ${month} ${cell.day}, ${cell.year}`\n    return cell.count === 0\n      ? `No ${unit} on ${when}`\n      : `${cell.count} ${unit} on ${when}`\n  }\n\n  // The gap has to grow with the squares or a large heatmap reads as one solid block. A quarter of\n  // the square, floored at 2px, keeps the proportion the small default already has.\n  const size = Number.isFinite(cellSize) && cellSize > 0 ? cellSize : 11\n  const gap = Math.max(2, Math.round(size / 4))\n  const swatch = { width: `${size}px`, height: `${size}px` }\n  const runs = monthRuns(columnStarts, from)\n\n  return (\n    <div\n      ref={ref}\n      className={cn(\"inline-flex flex-col gap-2 text-muted-foreground\", className)}\n      {...props}\n    >\n      {/* A region that scrolls has to be reachable by keyboard, or a year of data is simply\n          unavailable to anyone not using a mouse — which is why this carries tabIndex and a\n          focus ring rather than being a bare overflow container. */}\n      <div\n        tabIndex={0}\n        role=\"group\"\n        aria-label={caption}\n        className=\"max-w-full overflow-x-auto rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n      >\n        <table\n          className=\"border-separate text-[10px] leading-none\"\n          style={{ borderSpacing: `${gap}px` }}\n        >\n          <caption className=\"sr-only\">{caption}</caption>\n          <thead>\n            <tr>\n              {/* Sits above the weekday column; there is nothing to say about it. */}\n              <th className=\"sr-only\" scope=\"col\">\n                {weekdayLabels[rowStart] ?? WEEKDAYS[rowStart]}\n              </th>\n              {runs.map((run, i) => {\n                const label = monthLabels[run.month] ?? MONTHS[run.month]\n                return (\n                  <th\n                    key={`${run.month}-${i}`}\n                    scope=\"col\"\n                    colSpan={run.span}\n                    className=\"p-0 text-left font-normal\"\n                  >\n                    {/* A one- or two-column run has no room for the name and would collide with\n                        its neighbour, so it is dropped from the picture but kept for the reader. */}\n                    <span className={run.span >= 3 ? undefined : \"sr-only\"}>\n                      {label.slice(0, 3)}\n                    </span>\n                  </th>\n                )\n              })}\n            </tr>\n          </thead>\n          <tbody>\n            {[0, 1, 2, 3, 4, 5, 6].map((row) => {\n              const weekday = (rowStart + row) % 7\n              const name = weekdayLabels[weekday] ?? WEEKDAYS[weekday]\n              return (\n                <tr key={row}>\n                  <th\n                    scope=\"row\"\n                    className=\"p-0 pr-1 text-right align-middle font-normal\"\n                  >\n                    {/* Every other row is named in the picture — seven stacked labels at this\n                        size is illegible — but all seven name their row to a screen reader. */}\n                    <span className={row % 2 === 1 ? undefined : \"sr-only\"}>\n                      {name.slice(0, 3)}\n                    </span>\n                  </th>\n                  {columnStarts.map((columnStart) => {\n                    const epochDay = columnStart + row\n                    if (epochDay < from || epochDay > to) {\n                      // Padding at the two ends of the range: a day that is not in the window is\n                      // not a day with nothing, and must not be drawn or announced as one.\n                      return <td key={columnStart} className=\"p-0\" />\n                    }\n                    const cell = cellFor(epochDay)\n                    const label = describe(cell)\n                    return (\n                      <td key={columnStart} className=\"p-0\">\n                        <span className=\"sr-only\">{label}</span>\n                        <span\n                          aria-hidden=\"true\"\n                          title={label}\n                          style={swatch}\n                          className={cn(\n                            \"block rounded-[2px] ring-1 ring-inset ring-foreground/5\",\n                            LEVEL_CLASS[cell.level]\n                          )}\n                        />\n                      </td>\n                    )\n                  })}\n                </tr>\n              )\n            })}\n          </tbody>\n        </table>\n      </div>\n      {showLegend ? (\n        // Hidden from assistive technology in full: it explains a colour ramp, and the counts it\n        // stands for are already on every square. Announcing five unlabelled swatches after 365\n        // labelled ones is noise, not information.\n        <div\n          aria-hidden=\"true\"\n          className=\"flex items-center gap-1 self-end text-[11px]\"\n        >\n          <span>{legendLabels[0]}</span>\n          {LEVEL_CLASS.map((tone, level) => (\n            <span\n              key={level}\n              style={swatch}\n              className={cn(\n                \"block rounded-[2px] ring-1 ring-inset ring-foreground/5\",\n                tone\n              )}\n            />\n          ))}\n          <span>{legendLabels[1]}</span>\n        </div>\n      ) : null}\n    </div>\n  )\n})\nCalendarHeatmap.displayName = \"CalendarHeatmap\"\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}