Activity Heatmap
Contribution-style grid for visualizing operational activity over time. Install Activity Heatmap from the VLLNT UI registry with the shadcn CLI.
Contribution-style grid for visualizing operational activity over time. Part of the data family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Activity Heatmap to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/activity-heatmap.jsonSource
import * as React from "react";
import { normalizeDate } from "../../lib/format";
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 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 = ({
as: Heading = "h2",
className,
data,
description,
endDate = new Date(),
ref,
title = "Activity heatmap",
weeks = 12,
...props
}: ActivityHeatmapProps & { ref?: React.Ref<HTMLDivElement> }) => {
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";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.