A GitHub-style heatmap calendar component built with React, Tailwind CSS, and shadcn/ui.
✔️ Copy-paste friendly ✔️ No charting libraries ✔️ Light & dark mode ✔️ Axis labels (months + weekdays) ✔️ Domain-agnostic (fitness, business, IoT, learning, etc.)
- 📅 Calendar heatmap (GitHub-style)
- 📆 Year selector — browse full calendar years with a built-in dropdown
- 🧭 Axis labels
- Months on top
- Weekdays on the left
- 🎨 Theme presets with copyable CSS variables
- 🌗 Light / dark mode ready
- 🧩 Domain-agnostic
- fitness activity
- business metrics
- support tickets
- learning progress
- IoT events
- 🧠 Smart data merging (duplicate dates are summed)
- 🧪 Tooltips & click handlers
- ⚡ No canvas, SVG, or heavy chart libraries
Live demo available in the repository / website.
Installs the component plus its card/tooltip dependencies in one step:
npx shadcn@latest add https://<your-deployed-domain>/r/heatmap-calendar.jsonThis registry item is served by this repo itself at
/r/heatmap-calendar.json(built vianpm run registry:build, which runs automatically beforenext build). If you deploy your own copy, setNEXT_PUBLIC_SITE_URLin your environment so the generated URL, sitemap, and Open Graph tags point at your real domain.
This component can also be copied directly into your project (like shadcn blocks).
1️⃣ Install dependencies
npm install clsx tailwind-merge lucide-react2️⃣ Add required shadcn/ui components
npx shadcn@latest add card tooltip3️⃣ Copy the component file into your project:
components/heatmap-calendar.tsx
No provider. No configuration. No build step.
| Prop | Type | Default | Description |
|---|---|---|---|
title |
string |
"Activity" |
Title displayed above the heatmap |
data |
HeatmapDatum[] |
required | Daily aggregated data |
rangeDays |
number |
365 |
Number of days to render (ending at endDate) |
endDate |
Date |
new Date() |
Last date of the heatmap range |
weekStartsOn |
0 | 1 |
1 |
Week start (0 = Sunday, 1 = Monday) |
cellSize |
number |
12 |
Cell size in pixels |
cellGap |
number |
3 |
Gap between cells in pixels |
axisLabels |
boolean | AxisLabelConfig |
true |
Show or configure axis labels |
legend |
boolean | LegendConfig |
true |
Show or configure the legend |
palette |
string[] |
— | Custom color palette for intensity levels |
levelClassNames |
string[] |
semantic defaults | Tailwind classes for intensity levels |
scale |
"fixed" | "quantile" |
"fixed" |
How values map to levels (see below) |
thresholds |
number[] |
[2, 5, 10] |
Cutoffs used when scale="fixed" |
getLevel |
(value: number) => number |
— | Full custom value → level mapping |
year |
number |
— | Show this calendar year (controlled) |
defaultYear |
number |
— | Initial calendar year (uncontrolled) |
onYearChange |
(year: number) => void |
— | Called when the year changes |
yearSelector |
boolean | YearSelectorConfig |
false |
Show a built-in year dropdown |
onCellClick |
(cell: HeatmapCell) => void |
— | Called when a cell is clicked |
renderTooltip |
(cell: HeatmapCell) => ReactNode |
— | Custom tooltip renderer |
renderLegend |
(args) => ReactNode |
— | Fully custom legend renderer |
className |
string |
— | Additional class names for the card |
The heatmap expects daily aggregated data.
export type HeatmapDatum = {
date: string | Date; // "YYYY-MM-DD" recommended
value: number; // daily intensity
meta?: unknown; // optional metadata
};Example
const data = [
{ date: "2025-01-01", value: 3 },
{ date: "2025-01-02", value: 0 },
{ date: "2025-01-03", value: 8 },
];Notes
- Duplicate dates are automatically merged (values are summed)
- Use "YYYY-MM-DD" strings to avoid timezone issues
- Values can represent anything: minutes, orders, tickets, events, etc.
import { HeatmapCalendar } from "@/components/heatmap-calendar"
export default function Example() {
return (
<HeatmapCalendar
title="Activity"
data={data}
axisLabels
/>
)
}Axis labels are enabled by default and show:
- 📅 Months on top
- 📆 Weekdays on the left
<HeatmapCalendar data={data} axisLabels />Customize axis labels
<HeatmapCalendar
data={data}
axisLabels={{
showMonths: true,
showWeekdays: true,
weekdayIndices: [1, 3, 5], // Mon / Wed / Fri
monthFormat: "short", // "short" | "long" | "numeric"
minWeekSpacing: 3,
}}
/>Disable completely:
<HeatmapCalendar data={data} axisLabels={false} />The default buckets ([2, 5, 10]) are shaped for GitHub-commit-style counts. For other
units (minutes, dollars, tickets...), either set your own cutoffs or let the buckets be
derived from your data automatically:
// Fixed unit? Set your own cutoffs.
<HeatmapCalendar data={data} thresholds={[50, 150, 300]} />
// Unknown/variable unit? Derive buckets from the data itself.
<HeatmapCalendar data={data} scale="quantile" />
// Full control.
<HeatmapCalendar data={data} getLevel={(value) => (value > 100 ? 4 : value > 10 ? 2 : 0)} />Switch between full calendar years (Jan 1 – Dec 31), GitHub-profile style, with a built-in dropdown next to the title:
<HeatmapCalendar data={data} axisLabels yearSelector />Uncontrolled with a custom starting year and year list:
<HeatmapCalendar
data={data}
defaultYear={2024}
yearSelector={{ years: [2024, 2023, 2022] }}
/>Fully controlled (you own the state):
const [year, setYear] = React.useState(2025)
<HeatmapCalendar data={data} year={year} onYearChange={setYear} yearSelector />Dates after today are rendered as "Upcoming" and are not clickable.
<HeatmapCalendar
data={data}
onCellClick={(cell) => {
console.log(cell.date, cell.value, cell.meta)
}}
renderTooltip={(cell) => (
<div>
<div className="font-medium">{cell.value} events</div>
<div className="text-muted-foreground">{cell.label}</div>
</div>
)}
/>Contributions are welcome!
- Bug fixes
- Performance improvements
- Documentation improvements
- New examples
Feel free to open an issue or pull request.
MIT © Minh (Marcus) Nguyen
Made with ❤️ by Minh (Marcus) Nguyen