Calendar Heatmap

Month-by-month calendar heatmap with customizable color variants and weighted date support

Installation

pnpm dlx shadcn@latest add "https://vritti.thesatyajit.com/r/calendar-heatmap"

Dependencies

pnpm add react-day-picker date-fns lucide-react

Examples

Circle

"use client";

import { CalendarHeatmap } from "@/components/vritti/calendar-heatmap";
import type { WeightedDateEntry } from "@/components/vritti/calendar-heatmap";

function randomDate(start: Date, end: Date): Date {
  return new Date(
    start.getTime() + Math.random() * (end.getTime() - start.getTime())
  );
}

const OceanBlue = [
  "text-white hover:text-white bg-sky-200 hover:bg-sky-200",
  "text-white hover:text-white bg-sky-400 hover:bg-sky-400",
  "text-white hover:text-white bg-blue-500 hover:bg-blue-500",
  "text-white hover:text-white bg-blue-700 hover:bg-blue-700",
  "text-white hover:text-white bg-indigo-800 hover:bg-indigo-800",
];

const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - 5, 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);

const weightedDates: WeightedDateEntry[] = [...Array(80)].map(() => ({
  date: randomDate(start, end),
  weight: Math.floor(Math.random() * 100),
}));

export function CalendarHeatmapCircleExample() {
  return (
    <div className="flex items-center justify-center overflow-x-auto p-4">
      <CalendarHeatmap
        shape="circle"
        numberOfMonths={6}
        variantClassnames={OceanBlue}
        weightedDates={weightedDates}
        startMonth={start}
        defaultMonth={start}
      />
    </div>
  );
}

Interactive

"use client";

import { useState } from "react";
import { format } from "date-fns";
import { CalendarHeatmap } from "@/components/vritti/calendar-heatmap";
import type { WeightedDateEntry } from "@/components/vritti/calendar-heatmap";

function randomDate(start: Date, end: Date): Date {
  return new Date(
    start.getTime() + Math.random() * (end.getTime() - start.getTime())
  );
}

const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 2, 0);

const Heatmap = [
  "text-white hover:text-white bg-blue-300 hover:bg-blue-300 cursor-pointer",
  "text-white hover:text-white bg-blue-400 hover:bg-blue-400 cursor-pointer",
  "text-white hover:text-white bg-blue-500 hover:bg-blue-500 cursor-pointer",
  "text-white hover:text-white bg-blue-700 hover:bg-blue-700 cursor-pointer",
];

const weightedDates: WeightedDateEntry[] = [...Array(30)].map(() => ({
  date: randomDate(start, end),
  weight: Math.floor(Math.random() * 100) + 1,
}));

export function CalendarHeatmapInteractiveExample() {
  const [selected, setSelected] = useState<{
    date: string;
    weight: number;
  } | null>(null);

  return (
    <div className="flex flex-col items-center justify-center gap-4 overflow-x-auto p-4">
      <CalendarHeatmap
        numberOfMonths={2}
        variantClassnames={Heatmap}
        weightedDates={weightedDates}
        tooltipContent={(date) => {
          const entry = weightedDates.find(
            (e) =>
              format(e.date, "yyyy-MM-dd") === format(date, "yyyy-MM-dd")
          );
          return (
            <div className="text-sm">
              <p className="font-medium">{format(date, "MMM d, yyyy")}</p>
              <p className="text-muted-foreground">
                {entry?.weight ?? 0} events
              </p>
            </div>
          );
        }}
        onDayClick={(date) => {
          const dateKey = format(date, "yyyy-MM-dd");
          const entry = weightedDates.find(
            (e) => format(e.date, "yyyy-MM-dd") === dateKey
          );
          if (entry) {
            setSelected({
              date: format(date, "MMMM d, yyyy"),
              weight: entry.weight,
            });
          } else {
            setSelected(null);
          }
        }}
      />
      {selected ? (
        <div className="rounded-lg border bg-card p-3 text-sm shadow-sm">
          <p className="font-medium">{selected.date}</p>
          <p className="text-muted-foreground">{selected.weight} events recorded</p>
        </div>
      ) : (
        <p className="text-sm text-muted-foreground">
          Click on a highlighted date to see details
        </p>
      )}
    </div>
  );
}

Multi Month

"use client";

import { CalendarHeatmap } from "@/components/vritti/calendar-heatmap";
import type { WeightedDateEntry } from "@/components/vritti/calendar-heatmap";

function randomDate(start: Date, end: Date): Date {
  return new Date(
    start.getTime() + Math.random() * (end.getTime() - start.getTime())
  );
}

const Thermal = [
  "text-white hover:text-white bg-sky-200 hover:bg-sky-200",
  "text-white hover:text-white bg-sky-400 hover:bg-sky-400",
  "text-white hover:text-white bg-amber-300 hover:bg-amber-300",
  "text-white hover:text-white bg-orange-500 hover:bg-orange-500",
  "text-white hover:text-white bg-red-600 hover:bg-red-600",
];

const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - 5, 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);

const weightedDates: WeightedDateEntry[] = [...Array(80)].map(() => ({
  date: randomDate(start, end),
  weight: Math.floor(Math.random() * 100),
}));

export function CalendarHeatmapMultiMonthExample() {
  return (
    <div className="flex items-center justify-center overflow-x-auto p-4">
      <CalendarHeatmap
        numberOfMonths={6}
        variantClassnames={Thermal}
        weightedDates={weightedDates}
        startMonth={start}
        defaultMonth={start}
      />
    </div>
  );
}

Rainbow

"use client";

import { CalendarHeatmap } from "@/components/vritti/calendar-heatmap";

function randomDate(start: Date, end: Date): Date {
  return new Date(
    start.getTime() + Math.random() * (end.getTime() - start.getTime())
  );
}

const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 2, 0);

const Rainbow = [
  "text-white hover:text-white bg-violet-400 hover:bg-violet-400",
  "text-white hover:text-white bg-indigo-400 hover:bg-indigo-400",
  "text-white hover:text-white bg-blue-400 hover:bg-blue-400",
  "text-white hover:text-white bg-green-400 hover:bg-green-400",
  "text-white hover:text-white bg-yellow-400 hover:bg-yellow-400",
  "text-white hover:text-white bg-orange-400 hover:bg-orange-400",
  "text-white hover:text-white bg-red-400 hover:bg-red-400",
];

const RainbowDates = Rainbow.map((_, i) =>
  [...Array(i % 2 === 0 ? 3 : 2)].map(() => randomDate(start, end))
);

export function CalendarHeatmapRainbowExample() {
  return (
    <div className="flex items-center justify-center p-4">
      <CalendarHeatmap
        numberOfMonths={2}
        variantClassnames={Rainbow}
        datesPerVariant={RainbowDates}
      />
    </div>
  );
}

Tooltip

"use client";

import { format } from "date-fns";
import { CalendarHeatmap } from "@/components/vritti/calendar-heatmap";
import type { WeightedDateEntry } from "@/components/vritti/calendar-heatmap";

function randomDate(start: Date, end: Date): Date {
  return new Date(
    start.getTime() + Math.random() * (end.getTime() - start.getTime())
  );
}

const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 2, 0);

const GithubStreak = [
  "text-white hover:text-white bg-green-300 hover:bg-green-300",
  "text-white hover:text-white bg-green-400 hover:bg-green-400",
  "text-white hover:text-white bg-green-500 hover:bg-green-500",
  "text-white hover:text-white bg-green-700 hover:bg-green-700",
];

const weightedDates: WeightedDateEntry[] = [...Array(30)].map(() => ({
  date: randomDate(start, end),
  weight: Math.floor(Math.random() * 20) + 1,
}));

export function CalendarHeatmapTooltipExample() {
  return (
    <div className="flex items-center justify-center overflow-x-auto p-4">
      <CalendarHeatmap
        numberOfMonths={2}
        variantClassnames={GithubStreak}
        weightedDates={weightedDates}
        tooltipContent={(date) => (
          <div className="text-sm">
            <p className="font-medium">{format(date, "MMM d, yyyy")}</p>
            <p className="text-muted-foreground">
              {weightedDates.find(
                (e) => format(e.date, "yyyy-MM-dd") === format(date, "yyyy-MM-dd")
              )?.weight ?? 0}{" "}
              contributions
            </p>
          </div>
        )}
      />
    </div>
  );
}

Weighted

"use client";

import { CalendarHeatmap } from "@/components/vritti/calendar-heatmap";
import type { WeightedDateEntry } from "@/components/vritti/calendar-heatmap";

function randomDate(start: Date, end: Date): Date {
  return new Date(
    start.getTime() + Math.random() * (end.getTime() - start.getTime())
  );
}

const Heatmap = [
  "text-white hover:text-white bg-blue-300 hover:bg-blue-300",
  "text-white hover:text-white bg-green-500 hover:bg-green-500",
  "text-white hover:text-white bg-amber-400 hover:bg-amber-400",
  "text-white hover:text-white bg-red-700 hover:bg-red-700",
];

const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 2, 0);

const weightedDates: WeightedDateEntry[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map(
  (weight) => ({
    date: randomDate(start, end),
    weight,
  })
);

export function CalendarHeatmapWeightedExample() {
  return (
    <div className="flex items-center justify-center p-4">
      <CalendarHeatmap
        numberOfMonths={2}
        variantClassnames={Heatmap}
        weightedDates={weightedDates}
      />
    </div>
  );
}