A GitHub-style calendar heatmap of daily contribution counts. Cells are colored across a 5-level intensity scale, grouped into 53 weeks with month labels and day-of-week labels. Hover any cell for a tooltip showing the exact date and count.
// Dependencies: react ^18
import React, { useState, useRef } from 'react';
type DataPoint = {
date: string; // YYYY-MM-DD
count: number;
};
type Props = {
data: DataPoint[];
colorScale?: [string, string, string, string, string];
};
const DEFAULT_SCALE: [string, string, string, string, string] = [
'#1a1a1a', '#14532d', '#166534', '#16a34a', '#4ade80',
];
function getLevel(count: number): 0 | 1 | 2 | 3 | 4 {
if (count === 0) return 0;
if (count <= 2) return 1;
if (count <= 5) return 2;
if (count <= 10) return 3;
return 4;
}
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
function formatDate(dateStr: string): string {
return new Date(dateStr + 'T12:00:00').toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
});
}
export function ActivityHeatmap({ data, colorScale = DEFAULT_SCALE }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [tooltip, setTooltip] = useState<{
x: number; y: number; date: string; count: number;
} | null>(null);
const countMap = new Map(data.map((d) => [d.date, d.count]));
const total = data.reduce((s, d) => s + d.count, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
const start = new Date(today);
start.setDate(start.getDate() - 364 - start.getDay());
const weeks: string[][] = [];
const cur = new Date(start);
for (let w = 0; w < 53; w++) {
const week: string[] = [];
for (let d = 0; d < 7; d++) {
week.push(toDateStr(cur));
cur.setDate(cur.getDate() + 1);
}
weeks.push(week);
}
const monthLabels: { label: string; col: number }[] = [];
let lastMonth = -1;
weeks.forEach((week, col) => {
const m = new Date(week[0] + 'T12:00:00').getMonth();
if (m !== lastMonth) {
monthLabels.push({
label: new Date(week[0] + 'T12:00:00').toLocaleDateString('en-US', { month: 'short' }),
col,
});
lastMonth = m;
}
});
const todayStr = toDateStr(today);
const handleEnter = (
e: React.MouseEvent<HTMLButtonElement>,
dateStr: string,
count: number,
) => {
const container = containerRef.current;
if (!container) return;
const cr = container.getBoundingClientRect();
const er = e.currentTarget.getBoundingClientRect();
setTooltip({
x: er.left - cr.left + er.width / 2,
y: er.top - cr.top,
date: dateStr,
count,
});
};
return (
<div className="w-full bg-[#0a0a0a] border border-[#1a1a1a] rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-white">Activity</span>
<span className="text-[11px] text-zinc-500">
{total.toLocaleString()} contributions in the last year
</span>
</div>
<div
ref={containerRef}
className="relative select-none"
onMouseLeave={() => setTooltip(null)}
>
<div className="overflow-x-auto">
<div className="min-w-max">
{/* Month labels — fixed-width cells aligned to week columns */}
<div className="flex gap-[2px] mb-1">
<div className="shrink-0 w-[22px]" />
{weeks.map((_, col) => {
const ml = monthLabels.find((m) => m.col === col);
return (
<div key={col} className="shrink-0 w-[10px] text-[9px] text-zinc-600 overflow-hidden">
{ml?.label ?? ''}
</div>
);
})}
</div>
{/* Grid */}
<div className="flex gap-[2px]">
{/* Day labels */}
<div className="flex flex-col gap-[2px] pr-1">
{['', 'M', '', 'W', '', 'F', ''].map((label, i) => (
<div
key={i}
className="w-4 h-[10px] flex items-center justify-end text-[9px] text-zinc-700"
>
{label}
</div>
))}
</div>
{/* Weeks */}
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col gap-[2px]">
{week.map((dateStr) => {
const count = countMap.get(dateStr) ?? 0;
const isFuture = dateStr > todayStr;
const level = isFuture ? 0 : getLevel(count);
return (
<button
key={dateStr}
className="w-[10px] h-[10px] rounded-[2px] focus:outline-none focus-visible:ring-1 focus-visible:ring-indigo-500/60"
style={{
backgroundColor: colorScale[level],
opacity: isFuture ? 0.25 : 1,
}}
onMouseEnter={(e) => !isFuture && handleEnter(e, dateStr, count)}
aria-label={isFuture ? undefined : `${count} contributions on ${formatDate(dateStr)}`}
tabIndex={isFuture ? -1 : 0}
/>
);
})}
</div>
))}
</div>
</div>
</div>
{/* Tooltip — outside scroll container so it isn't clipped */}
{tooltip && (
<div
className="absolute pointer-events-none z-50 bg-[#1a1a1a] border border-[#2a2a2a] rounded-md px-2 py-1 text-[11px] whitespace-nowrap shadow-xl"
style={{
left: tooltip.x,
top: tooltip.y > 60 ? tooltip.y - 44 : tooltip.y + 16,
transform: 'translateX(-50%)',
}}
>
<span className="font-semibold text-white">{tooltip.count}</span>
<span className="text-zinc-400"> contributions · {formatDate(tooltip.date)}</span>
</div>
)}
{/* Legend */}
<div className="flex items-center gap-1 mt-2 justify-end">
<span className="text-[9px] text-zinc-600 mr-0.5">Less</span>
{colorScale.map((color, i) => (
<div key={i} className="w-[10px] h-[10px] rounded-[2px]" style={{ backgroundColor: color }} />
))}
<span className="text-[9px] text-zinc-600 ml-0.5">More</span>
</div>
</div>
</div>
);
}
// Usage:
// const data = [
// { date: '2025-06-01', count: 4 },
// { date: '2025-06-02', count: 12 },
// ];
// <ActivityHeatmap data={data} />Unlock to copy
Free access to all patterns
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Error State with Retry
Notion
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Usage / Quota Meter
Vercel
Skeleton Loading Grid
Linear
Activity Heatmap
Notion
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Comment Thread
Linear
Audit Log Timeline
Vercel
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Tag Multi-Select
Linear
Toggle Switch Group
Stripe
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Status Badge System
Linear
Segmented Control
Notion