Activity Heatmap

Contribution-style grid for visualizing operational activity over time.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/activity-heatmap.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

import * as React from "react"; import type { HeadingTag } from "../../lib/types"; import { cn } from "../../lib/utils"; export type ActivityHeatmapItem = { count: number; date: string; }; export type ActivityHeatmapProps = React.ComponentPropsWithoutRef<"div"> & { /** Heading tag for the title. Defaults to `h2`. */ as?: HeadingTag; data: ActivityHeatmapItem[]; description?: string; endDate?: Date | number | string; title?: string; weeks?: number; }; type DayCell = { count: number; date: Date; key: string; level: number; }; const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const VISIBLE_DAY_LABELS = new Set(["Mon", "Wed", "Fri"]); const LEVEL_CLASS_NAMES = [ "bg-muted", "bg-emerald-500/25", "bg-emerald-500/45", "bg-emerald-500/65", "bg-emerald-500", ]; function normalizeDate(input: Date | number | string): Date { if (input instanceof Date) { return new Date(input.getTime()); } return new Date(input); } function toUtcDate(date: Date): Date { return new Date( Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), ); } function addUtcDays(date: Date, days: number): Date { return new Date( Date.UTC( date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days, ), ); } function formatDayKey(date: Date): string { return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; } function getIntensityLevel(count: number, maxCount: number): number { if (count <= 0 || maxCount <= 0) { return 0; } const ratio = count / maxCount; if (ratio < 0.25) { return 1; } if (ratio < 0.5) { return 2; } if (ratio < 0.75) { return 3; } return 4; } function getGridData( data: ActivityHeatmapItem[], endDate: Date, weeks: number, ): DayCell[][] { const normalizedEnd = toUtcDate(endDate); const startDate = addUtcDays(normalizedEnd, -(weeks * 7 - 1)); const countsByDate = new Map<string, number>(); data.forEach((item) => { const normalizedDate = toUtcDate(normalizeDate(item.date)); countsByDate.set(formatDayKey(normalizedDate), item.count); }); const maxCount = Math.max(...data.map((item) => item.count), 0); return Array.from({ length: weeks }, (_, weekIndex) => { return Array.from({ length: 7 }, (_, dayIndex) => { const date = addUtcDays(startDate, weekIndex * 7 + dayIndex); const key = formatDayKey(date); const count = countsByDate.get(key) ?? 0; return { count, date, key, level: getIntensityLevel(count, maxCount), }; }); }); } const MONTH_LABEL_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC", }); const TOOLTIP_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", timeZone: "UTC", year: "numeric", }); function formatMonthLabel(date: Date): string { return MONTH_LABEL_FORMATTER.format(date); } function formatTooltip(date: Date, count: number): string { const formattedDate = TOOLTIP_DATE_FORMATTER.format(date); return `${count} activity ${count === 1 ? "event" : "events"} on ${formattedDate}`; } function HeatmapGrid({ gridData, weeks, }: { gridData: DayCell[][]; weeks: number; }) { return ( <div className="min-w-[640px] space-y-3"> <div className="grid gap-2 text-xs text-muted-foreground" style={{ gridTemplateColumns: `40px repeat(${weeks}, minmax(0, 1fr))` }} > <span /> {gridData.map((week) => ( <span className="text-center" key={`month-${week[0]?.key}`}> {week[0] && week[0].date.getUTCDate() <= 7 ? formatMonthLabel(week[0].date) : ""} </span> ))} </div> <div className="grid gap-2" style={{ gridTemplateColumns: `40px repeat(${weeks}, minmax(0, 1fr))` }} > <div className="grid grid-rows-7 gap-2 pt-1 text-xs text-muted-foreground"> {WEEKDAY_LABELS.map((label) => ( <span className="h-4 leading-4" key={label}> {VISIBLE_DAY_LABELS.has(label) ? label : ""} </span> ))} </div> {gridData.map((week) => ( <div className="grid grid-rows-7 gap-2" key={`week-${week[0]?.key}`}> {week.map((day) => ( <div className={cn( "h-4 rounded-sm border border-border/40 transition-colors", LEVEL_CLASS_NAMES[day.level], )} key={day.key} role="img" title={formatTooltip(day.date, day.count)} /> ))} </div> ))} </div> <div className="flex items-center justify-end gap-2 text-xs text-muted-foreground"> <span>Less</span> {LEVEL_CLASS_NAMES.map((className) => ( <span className={cn( "size-3 rounded-[3px] border border-border/40", className, )} key={`legend-${className}`} /> ))} <span>More</span> </div> </div> ); } export const ActivityHeatmap = React.forwardRef< HTMLDivElement, ActivityHeatmapProps >( ( { as: Heading = "h2", className, data, description, endDate = new Date(), title = "Activity heatmap", weeks = 12, ...props }, ref, ) => { const normalizedEndDate = normalizeDate(endDate); const gridData = getGridData(data, normalizedEndDate, weeks); return ( <div className={cn("space-y-4", className)} ref={ref} {...props}> <div className="space-y-1"> <Heading className="text-lg font-semibold tracking-tight"> {title} </Heading> {description ? ( <p className="text-sm text-muted-foreground">{description}</p> ) : null} </div> <div className="overflow-x-auto rounded-lg border bg-card p-4 shadow-sm"> <HeatmapGrid gridData={gridData} weeks={weeks} /> </div> </div> ); }, ); ActivityHeatmap.displayName = "ActivityHeatmap";

Dependencies

  • @vllnt/ui@^0.2.1