Calendar Heatmap
GitHub-style contribution heatmap with month and weekday labels, tooltips, configurable intensity scale and a legend.
Example
Installation
npx shadcn@latest add https://hirael.com/r/calendar-heatmap.jsonAPI
<CalendarHeatmap />
+ native element props| Prop | Type | Default |
|---|---|---|
data* | CalendarHeatmapDatum[] | — |
endDate | Date | — |
startDate | Date | — |
months | number | 12 |
weekStartsOn | 0 | 1 | 0 |
levels | number | 5 |
thresholds | number[] | — |
classForLevel | ((level: number) => string) | — |
cellSize | number | "sm" | "md" | "lg" | "md" |
gap | number | 3 |
locale | string | — |
showMonthLabels | boolean | true |
showWeekdayLabels | boolean | true |
tooltipFormatter | ((date: Date, value: number) => React.ReactNode) | — |
onSelectDay | ((date: Date, value: number) => void) | — |
<CalendarHeatmapLegend />
+ native element props| Prop | Type | Default |
|---|---|---|
levels | number | 5 |
classForLevel | ((level: number) => string) | — |
cellSize | number | "sm" | "md" | "lg" | "md" |
gap | number | 3 |
lessLabel | React.ReactNode | "Less" |
moreLabel | React.ReactNode | "More" |
Component source
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/hirael/ui/tooltip";
const CELL_SIZES = { sm: 10, md: 12, lg: 16 } as const;
const DEFAULT_LEVEL_CLASSES = [
"bg-muted",
"bg-primary/25",
"bg-primary/50",
"bg-primary/75",
"bg-primary",
];
function defaultClassForLevel(level: number, levels: number) {
if (level <= 0 || levels <= 1) return DEFAULT_LEVEL_CLASSES[0];
const index = Math.max(1, Math.round((level / (levels - 1)) * 4));
return DEFAULT_LEVEL_CLASSES[Math.min(index, 4)];
}
function toLocalDate(input: Date | string): Date {
if (input instanceof Date) {
return new Date(input.getFullYear(), input.getMonth(), input.getDate());
}
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(input);
if (match) {
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
const parsed = new Date(input);
return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
}
function addDays(date: Date, days: number): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
}
function addMonthsClamped(date: Date, months: number): Date {
const year = date.getFullYear();
const month = date.getMonth() + months;
const lastDay = new Date(year, month + 1, 0).getDate();
return new Date(year, month, Math.min(date.getDate(), lastDay));
}
function dayKey(date: Date): number {
return (
date.getFullYear() * 10000 + (date.getMonth() + 1) * 100 + date.getDate()
);
}
function resolveCellSize(cellSize: "sm" | "md" | "lg" | number): number {
return typeof cellSize === "number" ? cellSize : CELL_SIZES[cellSize];
}
type CalendarHeatmapDatum = {
date: Date | string;
value: number;
};
type CalendarHeatmapProps = Omit<React.ComponentProps<"div">, "onSelect"> & {
data: CalendarHeatmapDatum[];
endDate?: Date;
startDate?: Date;
months?: number;
weekStartsOn?: 0 | 1;
levels?: number;
thresholds?: number[];
classForLevel?: (level: number) => string;
cellSize?: "sm" | "md" | "lg" | number;
gap?: number;
locale?: string;
showMonthLabels?: boolean;
showWeekdayLabels?: boolean;
tooltipFormatter?: (date: Date, value: number) => React.ReactNode;
onSelectDay?: (date: Date, value: number) => void;
};
function CalendarHeatmap({
data,
endDate,
startDate,
months = 12,
weekStartsOn = 0,
levels = 5,
thresholds,
classForLevel,
cellSize = "md",
gap = 3,
locale,
showMonthLabels = true,
showWeekdayLabels = true,
tooltipFormatter,
onSelectDay,
className,
...props
}: CalendarHeatmapProps) {
const size = resolveCellSize(cellSize);
const today = React.useMemo(() => toLocalDate(new Date()), []);
const { cells, weekCount, monthLabels, weekdayLabels } = React.useMemo(() => {
const rangeEnd = endDate ? toLocalDate(endDate) : today;
const rangeStart = startDate
? toLocalDate(startDate)
: addDays(addMonthsClamped(rangeEnd, -months), 1);
const values = new Map<number, number>();
for (const datum of data) {
const key = dayKey(toLocalDate(datum.date));
values.set(key, (values.get(key) ?? 0) + datum.value);
}
const leading = (rangeStart.getDay() - weekStartsOn + 7) % 7;
const gridStart = addDays(rangeStart, -leading);
const totalDays =
Math.round((rangeEnd.getTime() - gridStart.getTime()) / 86400000) + 1;
const weeks = Math.ceil(totalDays / 7);
let max = 0;
for (let i = 0; i < weeks * 7; i++) {
const date = addDays(gridStart, i);
if (date < rangeStart || date > rangeEnd) continue;
max = Math.max(max, values.get(dayKey(date)) ?? 0);
}
if (max <= 0) max = 1;
const cuts =
thresholds ??
Array.from(
{ length: Math.max(levels - 1, 0) },
(_, i) => (max * (i + 1)) / Math.max(levels - 1, 1),
);
const levelFor = (value: number) => {
if (value <= 0) return 0;
let level = 0;
for (const cut of cuts) {
if (value >= cut) level += 1;
}
return Math.min(level, levels - 1);
};
const dayFormatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short" });
const grid: {
date: Date;
value: number;
level: number;
inRange: boolean;
}[] = [];
for (let i = 0; i < weeks * 7; i++) {
const date = addDays(gridStart, i);
const inRange = date >= rangeStart && date <= rangeEnd;
const value = inRange ? (values.get(dayKey(date)) ?? 0) : 0;
grid.push({ date, value, level: inRange ? levelFor(value) : 0, inRange });
}
const candidates: { weekIndex: number; label: string }[] = [];
let lastMonth = -1;
for (let i = 0; i < grid.length; i++) {
const cell = grid[i];
if (!cell.inRange) continue;
const month = cell.date.getMonth();
if (month !== lastMonth) {
lastMonth = month;
candidates.push({
weekIndex: Math.floor(i / 7),
label: monthFormatter.format(cell.date),
});
}
}
const labels = candidates.filter((candidate, i) => {
const next = candidates[i + 1];
return !next || next.weekIndex - candidate.weekIndex >= 3;
});
const weekdays = Array.from({ length: 7 }, (_, row) => {
const day = (weekStartsOn + row) % 7;
return {
label: dayFormatter.format(new Date(2024, 0, 7 + day)),
visible: day === 1 || day === 3 || day === 5,
};
});
return {
cells: grid,
weekCount: weeks,
monthLabels: labels,
weekdayLabels: weekdays,
};
}, [
data,
endDate,
startDate,
months,
weekStartsOn,
levels,
thresholds,
locale,
today,
]);
const resolveLevelClass =
classForLevel ?? ((level: number) => defaultClassForLevel(level, levels));
const defaultFormatter = React.useCallback(
(date: Date, value: number) => {
const formattedDate = new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
const formattedValue = new Intl.NumberFormat(locale).format(value);
return `${formattedValue} · ${formattedDate}`;
},
[locale],
);
const formatTooltip = tooltipFormatter ?? defaultFormatter;
const cellRefs = React.useRef(new Map<number, HTMLButtonElement | null>());
const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);
const defaultTabbableIndex = React.useMemo(() => {
const todayIndex = cells.findIndex(
(cell) => cell.inRange && dayKey(cell.date) === dayKey(today),
);
if (todayIndex !== -1) return todayIndex;
for (let i = cells.length - 1; i >= 0; i--) {
if (cells[i].inRange) return i;
}
return -1;
}, [cells, today]);
const tabbableIndex =
focusedIndex !== null && cells[focusedIndex]?.inRange
? focusedIndex
: defaultTabbableIndex;
const handleCellKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
index: number,
) => {
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
let target: number;
switch (event.key) {
case "ArrowUp":
target = index - 1;
break;
case "ArrowDown":
target = index + 1;
break;
case "ArrowLeft":
target = index + (rtl ? 7 : -7);
break;
case "ArrowRight":
target = index + (rtl ? -7 : 7);
break;
case "Home":
target = index - (index % 7);
while (target < index && !cells[target]?.inRange) target += 1;
break;
case "End":
target = index - (index % 7) + 6;
while (target > index && !cells[target]?.inRange) target -= 1;
break;
default:
return;
}
event.preventDefault();
if (target < 0 || target >= cells.length || !cells[target].inRange) return;
setFocusedIndex(target);
cellRefs.current.get(target)?.focus();
};
return (
<div
data-slot="calendar-heatmap"
className={cn("w-fit", className)}
{...props}
>
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<div className="flex flex-col" style={{ gap }}>
{showMonthLabels && (
<div className="flex" style={{ gap }}>
{showWeekdayLabels && (
<div aria-hidden className="w-8 shrink-0" />
)}
<div
data-slot="calendar-heatmap-months"
className="grid"
style={{
gridTemplateColumns: `repeat(${weekCount}, ${size}px)`,
columnGap: gap,
}}
>
{monthLabels.map((month) => (
<span
key={month.weekIndex}
data-slot="calendar-heatmap-month-label"
className="whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground"
style={{ gridColumnStart: month.weekIndex + 1, gridRow: 1 }}
>
{month.label}
</span>
))}
</div>
</div>
)}
<div className="flex" style={{ gap }}>
{showWeekdayLabels && (
<div
data-slot="calendar-heatmap-weekdays"
className="grid w-8 shrink-0"
style={{
gridTemplateRows: `repeat(7, ${size}px)`,
rowGap: gap,
}}
>
{weekdayLabels.map((weekday, row) => (
<span
key={row}
data-slot="calendar-heatmap-weekday-label"
className={cn(
"flex items-center text-[10px] leading-none text-muted-foreground",
!weekday.visible && "invisible",
)}
>
{weekday.label}
</span>
))}
</div>
)}
<div
data-slot="calendar-heatmap-grid"
className="grid grid-flow-col"
style={{
gridTemplateRows: `repeat(7, ${size}px)`,
gridTemplateColumns: `repeat(${weekCount}, ${size}px)`,
gap,
}}
>
{cells.map((cell, index) => {
if (!cell.inRange) {
return (
<div
key={cell.date.getTime()}
data-slot="calendar-heatmap-cell"
aria-hidden
className="invisible"
/>
);
}
const tooltip = formatTooltip(cell.date, cell.value);
const label =
typeof tooltip === "string"
? tooltip
: defaultFormatter(cell.date, cell.value);
const cellClassName = cn(
"rounded-[2px]",
resolveLevelClass(cell.level),
onSelectDay &&
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
);
return (
<Tooltip key={cell.date.getTime()}>
<TooltipTrigger asChild>
{onSelectDay ? (
<button
type="button"
ref={(el) => {
cellRefs.current.set(index, el);
}}
data-slot="calendar-heatmap-cell"
data-level={cell.level}
aria-label={label}
tabIndex={index === tabbableIndex ? 0 : -1}
className={cellClassName}
onFocus={() => setFocusedIndex(index)}
onKeyDown={(event) => handleCellKeyDown(event, index)}
onClick={() => onSelectDay(cell.date, cell.value)}
/>
) : (
<div
role="img"
data-slot="calendar-heatmap-cell"
data-level={cell.level}
aria-label={label}
className={cellClassName}
/>
)}
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
</div>
</TooltipProvider>
</div>
);
}
type CalendarHeatmapLegendProps = React.ComponentProps<"div"> & {
levels?: number;
classForLevel?: (level: number) => string;
cellSize?: "sm" | "md" | "lg" | number;
gap?: number;
lessLabel?: React.ReactNode;
moreLabel?: React.ReactNode;
};
function CalendarHeatmapLegend({
levels = 5,
classForLevel,
cellSize = "md",
gap = 3,
lessLabel = "Less",
moreLabel = "More",
className,
...props
}: CalendarHeatmapLegendProps) {
const size = resolveCellSize(cellSize);
const resolveLevelClass =
classForLevel ?? ((level: number) => defaultClassForLevel(level, levels));
return (
<div
data-slot="calendar-heatmap-legend"
className={cn(
"flex w-fit items-center gap-1.5 text-[10px] text-muted-foreground",
className,
)}
{...props}
>
<span data-slot="calendar-heatmap-legend-label">{lessLabel}</span>
<div className="flex" style={{ gap }}>
{Array.from({ length: levels }, (_, level) => (
<span
key={level}
data-slot="calendar-heatmap-legend-swatch"
data-level={level}
className={cn("rounded-[2px]", resolveLevelClass(level))}
style={{ width: size, height: size }}
/>
))}
</div>
<span data-slot="calendar-heatmap-legend-label">{moreLabel}</span>
</div>
);
}
export { CalendarHeatmap, CalendarHeatmapLegend };
export type { CalendarHeatmapDatum };
Dependencies
shadcn registry
tooltip