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
bash

Storybook

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

View in Storybook

2 stories available:

Code

import * as React from "react";

import { cn } from "../../lib/utils";

export type ActivityHeatmapItem = {
  count: number;
  date: string;
};

export type ActivityHeatmapProps = React.ComponentPropsWithoutRef<"div"> & {
  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
>(
  (
    {
      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">
          <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
          {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";
typescript

Dependencies

  • @vllnt/ui@^0.2.1