# Heatmap Calendar

> GitHub-style contribution calendar with five intensity levels, tooltips and roving-tabindex keyboard navigation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/heatmap-calendar
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/heatmap-calendar.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/heatmap-calendar-demo.tsx"
import { HeatmapCalendar, type HeatmapDatum } from "@/components/heatmap-calendar"

// Deterministic sample data: seeded PRNG at module scope, anchored to a fixed end date.
function mulberry32(seed: number) {
  let a = seed
  return () => {
    a = (a + 0x6d2b79f5) | 0
    let t = Math.imul(a ^ (a >>> 15), 1 | a)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

const DAY_MS = 86_400_000
const END_UTC = Date.UTC(2026, 10, 7)

function buildData(): HeatmapDatum[] {
  const rand = mulberry32(20261107)
  const out: HeatmapDatum[] = []
  for (let i = 0; i < 380; i++) {
    const ts = END_UTC - i * DAY_MS
    const weekday = new Date(ts).getUTCDay()
    const weekend = weekday === 0 || weekday === 6
    const season = 0.6 + 0.4 * Math.sin(i / 23)
    const idle = rand() < (weekend ? 0.6 : 0.2)
    const count = idle ? 0 : Math.max(1, Math.round(rand() ** 2 * 14 * season + 1))
    out.push({ date: new Date(ts).toISOString().slice(0, 10), count })
  }
  return out
}

const data = buildData()

export default function HeatmapCalendarDemo() {
  return (
    <div className="grid w-full max-w-3xl min-w-0 grid-cols-[minmax(0,1fr)] gap-6">
      <div className="min-w-0 rounded-2xl border bg-card p-5 shadow-sm">
        <p className="mb-4 text-sm font-medium">Contributions</p>
        <HeatmapCalendar data={data} title="Contributions" />
      </div>
      <div className="min-w-0 rounded-2xl border bg-card p-5 shadow-sm">
        <p className="mb-4 text-sm font-medium">Study sessions, last 26 weeks</p>
        <HeatmapCalendar data={data} weeks={26} tone="violet" unit="session" title="Study sessions" />
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/heatmap-calendar.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface HeatmapDatum {
  /** ISO date, YYYY-MM-DD */
  date: string
  count: number
}

export type HeatmapTone = "primary" | "emerald" | "sky" | "violet" | "amber"

export interface HeatmapCalendarProps extends Omit<React.ComponentProps<"div">, "children"> {
  data: HeatmapDatum[]
  /** Last day shown (YYYY-MM-DD). Fixed by default so output is deterministic. */
  endDate?: string
  weeks?: number
  tone?: HeatmapTone
  /** Singular unit name used in labels, e.g. "contribution" */
  unit?: string
  title?: string
}

export const HEATMAP_END_DATE = "2026-11-07"

const levelClass: Record<HeatmapTone, string[]> = {
  primary: ["bg-muted", "bg-primary/30", "bg-primary/55", "bg-primary/80", "bg-primary"],
  emerald: ["bg-muted", "bg-emerald-500/30", "bg-emerald-500/55", "bg-emerald-500/80", "bg-emerald-500"],
  sky: ["bg-muted", "bg-sky-500/30", "bg-sky-500/55", "bg-sky-500/80", "bg-sky-500"],
  violet: ["bg-muted", "bg-violet-500/30", "bg-violet-500/55", "bg-violet-500/80", "bg-violet-500"],
  amber: ["bg-muted", "bg-amber-500/30", "bg-amber-500/55", "bg-amber-500/80", "bg-amber-500"],
}

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
const CELL = 12
const GAP = 3
const PITCH = CELL + GAP
const TIP_HALF = 96
const number = new Intl.NumberFormat("en-US")

// Pure calendar maths on day numbers (days since 1970-01-01); no Date objects involved.
function daysFromCivil(y: number, m: number, d: number) {
  const yy = m <= 2 ? y - 1 : y
  const era = Math.floor(yy / 400)
  const yoe = yy - era * 400
  const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1
  const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy
  return era * 146097 + doe - 719468
}

function civilFromDays(days: number) {
  const z = days + 719468
  const era = Math.floor(z / 146097)
  const doe = z - era * 146097
  const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365)
  const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100))
  const mp = Math.floor((5 * doy + 2) / 153)
  const d = doy - Math.floor((153 * mp + 2) / 5) + 1
  const m = mp < 10 ? mp + 3 : mp - 9
  return { y: yoe + era * 400 + (m <= 2 ? 1 : 0), m, d }
}

const weekdayOf = (days: number) => (((days + 4) % 7) + 7) % 7
const pad = (n: number) => String(n).padStart(2, "0")

function isoOf(days: number) {
  const { y, m, d } = civilFromDays(days)
  return `${y}-${pad(m)}-${pad(d)}`
}

function parseIso(iso: string) {
  const [y, m, d] = iso.split("-").map(Number)
  return daysFromCivil(y, m, d)
}

function longDate(days: number) {
  const { y, m, d } = civilFromDays(days)
  return `${WEEKDAYS[weekdayOf(days)]}, ${MONTHS[m - 1]} ${d}, ${y}`
}

interface Tip {
  w: number
  d: number
  x: number
  y: number
  below: boolean
}

export function HeatmapCalendar({
  data,
  endDate = HEATMAP_END_DATE,
  weeks = 53,
  tone = "emerald",
  unit = "contribution",
  title = "Activity calendar",
  className,
  ...props
}: HeatmapCalendarProps) {
  const outerRef = React.useRef<HTMLDivElement>(null)
  const gridRef = React.useRef<HTMLDivElement>(null)
  const [focus, setFocus] = React.useState<{ w: number; d: number } | null>(null)
  const [tip, setTip] = React.useState<Tip | null>(null)

  // Start scrolled to the most recent week; stable callback so it only runs on mount.
  const scrollRef = React.useCallback((el: HTMLDivElement | null) => {
    if (el) el.scrollLeft = el.scrollWidth
  }, [])

  const plural = `${unit}s`
  const endDays = parseIso(endDate)
  const startDays = endDays - weekdayOf(endDays) - (weeks - 1) * 7

  const counts = React.useMemo(() => {
    const map = new Map<string, number>()
    for (const item of data) map.set(item.date, (map.get(item.date) ?? 0) + item.count)
    return map
  }, [data])

  const dayAt = (w: number, d: number) => startDays + w * 7 + d
  const exists = (w: number, d: number) => w >= 0 && w < weeks && d >= 0 && d < 7 && dayAt(w, d) <= endDays
  const countAt = (w: number, d: number) => counts.get(isoOf(dayAt(w, d))) ?? 0

  let total = 0
  let max = 0
  for (let w = 0; w < weeks; w++) {
    for (let d = 0; d < 7; d++) {
      if (!exists(w, d)) continue
      const c = countAt(w, d)
      total += c
      if (c > max) max = c
    }
  }
  const level = (c: number) => (c <= 0 || max <= 0 ? 0 : Math.min(4, Math.ceil((c / max) * 4)))
  const levels = levelClass[tone]

  // Month labels: column that contains the 1st of a month (plus the first column if there is room).
  const monthLabels: { w: number; text: string }[] = []
  for (let w = 0; w < weeks; w++) {
    for (let d = 0; d < 7; d++) {
      const { m, d: dom } = civilFromDays(dayAt(w, d))
      if (dom === 1) monthLabels.push({ w, text: MONTHS[m - 1] })
    }
  }
  if (monthLabels.length > 0 && monthLabels[0].w >= 3) {
    monthLabels.unshift({ w: 0, text: MONTHS[civilFromDays(startDays).m - 1] })
  }
  const lastWeekday = weekdayOf(endDays)
  const rover = focus && exists(focus.w, focus.d) ? focus : { w: weeks - 1, d: lastWeekday }

  function showTip(el: HTMLElement, w: number, d: number) {
    const outer = outerRef.current
    if (!outer) return
    const o = outer.getBoundingClientRect()
    const c = el.getBoundingClientRect()
    const x = Math.min(Math.max(c.left - o.left + c.width / 2, TIP_HALF), Math.max(o.width - TIP_HALF, TIP_HALF))
    const y = c.top - o.top
    setTip({ w, d, x, y: y < 44 ? y + c.height : y, below: y < 44 })
  }

  function move(w: number, d: number) {
    gridRef.current?.querySelector<HTMLElement>(`[data-w="${w}"][data-d="${d}"]`)?.focus()
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
    const target = e.target as HTMLElement
    const w = Number(target.dataset.w)
    const d = Number(target.dataset.d)
    if (Number.isNaN(w) || Number.isNaN(d)) return
    let next: { w: number; d: number } | null = null
    if (e.key === "ArrowRight") next = { w: w + 1, d }
    else if (e.key === "ArrowLeft") next = { w: w - 1, d }
    else if (e.key === "ArrowDown") next = { w, d: d + 1 }
    else if (e.key === "ArrowUp") next = { w, d: d - 1 }
    else if (e.key === "Home") next = { w: 0, d }
    else if (e.key === "End") next = { w: exists(weeks - 1, d) ? weeks - 1 : weeks - 2, d }
    else if (e.key === "PageUp") next = { w: w - 4, d }
    else if (e.key === "PageDown") next = { w: Math.min(w + 4, exists(weeks - 1, d) ? weeks - 1 : weeks - 2), d }
    if (!next) return
    e.preventDefault()
    if (exists(next.w, next.d)) move(next.w, next.d)
  }

  const gridWidth = weeks * PITCH - GAP
  const tipDay = tip ? dayAt(tip.w, tip.d) : 0
  const tipCount = tip ? countAt(tip.w, tip.d) : 0

  return (
    <div className={cn("w-full min-w-0", className)} {...props}>
      <div ref={outerRef} className="relative flex min-w-0 gap-2">
        <div aria-hidden className="shrink-0 pt-[22px] text-[10px] leading-3 text-muted-foreground">
          <div className="grid" style={{ rowGap: GAP }}>
            {WEEKDAYS.map((name, i) => (
              <span key={name} className="h-3 pr-1 text-right">
                {i % 2 === 1 ? name : ""}
              </span>
            ))}
          </div>
        </div>
        <div ref={scrollRef} className="min-w-0 flex-1 overflow-x-auto">
          <div className="w-max p-0.5">
            <div aria-hidden className="relative h-5 text-[10px] text-muted-foreground" style={{ width: gridWidth }}>
              {monthLabels.map((label) => (
                <span key={`${label.text}-${label.w}`} className="absolute top-0" style={{ left: label.w * PITCH }}>
                  {label.text}
                </span>
              ))}
            </div>
            <div
              ref={gridRef}
              role="grid"
              aria-label={`${title}: ${number.format(total)} ${total === 1 ? unit : plural} in ${weeks} weeks`}
              className="grid"
              style={{ rowGap: GAP }}
              onKeyDown={onKeyDown}
            >
              {WEEKDAYS.map((dayName, d) => (
                <div key={dayName} role="row" className="flex" style={{ columnGap: GAP }}>
                  {Array.from({ length: weeks }, (_, w) => {
                    if (!exists(w, d)) return <div key={w} role="presentation" style={{ width: CELL, height: CELL }} />
                    const c = countAt(w, d)
                    const days = dayAt(w, d)
                    return (
                      <div
                        key={w}
                        role="gridcell"
                        data-w={w}
                        data-d={d}
                        tabIndex={rover.w === w && rover.d === d ? 0 : -1}
                        aria-label={`${number.format(c)} ${c === 1 ? unit : plural} on ${longDate(days)}`}
                        style={{ width: CELL, height: CELL }}
                        className={cn(
                          "rounded-[3px] outline-none transition-shadow hover:ring-2 hover:ring-foreground/40 focus-visible:ring-2 focus-visible:ring-ring",
                          levels[level(c)]
                        )}
                        onPointerEnter={(e) => showTip(e.currentTarget, w, d)}
                        onPointerLeave={() => setTip(null)}
                        onFocus={(e) => {
                          setFocus({ w, d })
                          if (e.currentTarget.matches(":focus-visible")) showTip(e.currentTarget, w, d)
                        }}
                        onBlur={() => setTip(null)}
                      />
                    )
                  })}
                </div>
              ))}
            </div>
          </div>
        </div>
        {tip && (
          <div
            aria-hidden
            className={cn(
              "pointer-events-none absolute z-10 -translate-x-1/2 rounded-md bg-foreground px-2.5 py-1.5 text-xs whitespace-nowrap text-background shadow-md",
              tip.below ? "mt-1.5" : "-translate-y-full -mt-1.5"
            )}
            style={{ left: tip.x, top: tip.y }}
          >
            <span className="font-semibold tabular-nums">
              {number.format(tipCount)} {tipCount === 1 ? unit : plural}
            </span>{" "}
            on {longDate(tipDay)}
          </div>
        )}
      </div>
      <div className="mt-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-2 text-xs text-muted-foreground">
        <p>
          <span className="font-medium text-foreground tabular-nums">{number.format(total)}</span> {total === 1 ? unit : plural} in the last {weeks >= 52 ? "year" : `${weeks} weeks`}
        </p>
        <div className="flex items-center gap-1.5" aria-hidden>
          <span>Less</span>
          {levels.map((cls, i) => (
            <span key={i} className={cn("size-3 rounded-[3px]", cls)} />
          ))}
          <span>More</span>
        </div>
      </div>
    </div>
  )
}
```
