UI

GitHub Heatmap

An interactive 52-week contribution activity graph with date-fns interval calculation, theme-aware level colors, and date tooltips.

Props

Palette

Installation

GitHub Heatmap is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/github-map

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the components page.

Usage

import { GitHubCalendar } from "@/components/ui/github-map";
 
const contributions = [
  { date: "2025-01-01", count: 3 },
  { date: "2025-01-02", count: 8 },
  { date: "2025-01-03", count: 0 },
  // ... 365 days of activity
];
 
export function ActivitySection() {
  return (
    <GitHubCalendar
      data={contributions}
      colors={[
        "var(--heatmap-level-0)",
        "var(--heatmap-level-1)",
        "var(--heatmap-level-2)",
        "var(--heatmap-level-3)",
        "var(--heatmap-level-4)",
      ]}
    />
  );
}

Props

PropTypeDefaultNotes
data RequiredArray<{ date: string; count: number }>-Array of ISO date strings (YYYY-MM-DD) and integer contribution counts over a 12-month interval.
colorsstring[]5 CSS variables (--heatmap-level-0 through --heatmap-level-4)Array of 5 color values mapping to activity intensity levels 0 through 4+.

Notes & Features

  • Interval Auto-Calculation. Computes the start and end of week boundaries over the past 12 months using date-fns, preventing 30-day month drift.
  • Accessible Tooltips. Each individual day cell wraps a floating Shadcn tooltip displaying the formatted date (e.g. Sep 13, 2025: 5 contributions) for hover and screen readers.
  • Theme-Aware Color Palettes. Uses CSS custom variables (--heatmap-level-*) by default, seamlessly adapting to light, dark, and custom themes.
  • Legend Included. Renders a "Less → More" intensity swatch legend below the calendar grid.

Manual installation

Rather not use the CLI? Everything GitHub Heatmap needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install date-fns radix-ui

shadcn primitives

$npx shadcn@latest add tooltip

Source

components/ui/github-map.tsx176 lines
"use client";

import {
  addDays,
  eachDayOfInterval,
  endOfWeek,
  format,
  startOfWeek,
  subMonths,
} from "date-fns";
import { useMemo, useCallback } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";

interface ContributionDay {
  date: string;
  count: number;
}

interface GitHubCalendarProps {
  data: ContributionDay[];
  colors?: string[];
}

/** Parse a bare YYYY-MM-DD date as a local calendar date. */
export function parseLocalDate(iso: string): Date {
  const [year, month, day] = iso.split("-").map(Number);
  return new Date(year, month - 1, day);
}

/** Index contributions by ISO date for constant-time calendar cell lookup. */
export function indexByDate<T extends { date: string }>(
  data: T[],
): Map<string, T> {
  return new Map(data.map((entry) => [entry.date, entry]));
}

const GitHubCalendar = ({
  data,
  // Theme vars with literal fallbacks so the component renders outside this
  // repo too (consumers won't have --heatmap-level-* defined).
  colors = [
    "var(--heatmap-level-0, oklch(0.92 0 0))",
    "var(--heatmap-level-1, oklch(0.882 0.057 254.128))",
    "var(--heatmap-level-2, oklch(0.707 0.165 254.624))",
    "var(--heatmap-level-3, oklch(0.488 0.243 264.376))",
    "var(--heatmap-level-4, oklch(0.379 0.146 265.522))",
  ],
}: GitHubCalendarProps) => {
  const { startDate, endDate } = useMemo(() => {
    const end = new Date();
    return { startDate: subMonths(end, 12), endDate: end };
  }, []);

  const weeks = Math.ceil(
    (endDate.getTime() - startDate.getTime()) / (7 * 24 * 60 * 60 * 1000),
  );
  const contributionsByDate = useMemo(() => indexByDate(data), [data]);

  const getColor = useCallback(
    (count: number) => {
      if (count === 0) return colors[0];
      if (count === 1) return colors[1];
      if (count === 2) return colors[2];
      if (count === 3) return colors[3];
      return colors[4] || colors[colors.length - 1];
    },
    [colors],
  );

  const weeksData = useMemo(() => {
    const weeksList = [];
    let currentWeekStart = startOfWeek(startDate, { weekStartsOn: 0 });

    for (let i = 0; i < weeks; i++) {
      const weekDays = eachDayOfInterval({
        start: currentWeekStart,
        end: endOfWeek(currentWeekStart, { weekStartsOn: 0 }),
      });

      const daysList = weekDays.map((day) => {
        const dateKey = format(day, "yyyy-MM-dd");
        const formattedDate = format(day, "PPP");
        const contribution = contributionsByDate.get(dateKey);
        const count = contribution?.count ?? 0;
        return {
          key: dateKey,
          count,
          formattedDate,
          color: getColor(count),
        };
      });

      weeksList.push({ weekIndex: i, days: daysList });
      currentWeekStart = addDays(currentWeekStart, 7);
    }
    return weeksList;
  }, [startDate, weeks, contributionsByDate, getColor]);

  // Render month labels with proper alignment and solving the 30-day drift
  const monthLabelsData = useMemo(() => {
    const labels: { name: string; weekIndex: number }[] = [];
    let currentWeekStart = startOfWeek(startDate, { weekStartsOn: 0 });

    for (let i = 0; i < weeks; i++) {
      const monthName = format(currentWeekStart, "MMM");
      if (
        labels.length === 0 ||
        (labels[labels.length - 1].name !== monthName &&
          i - labels[labels.length - 1].weekIndex >= 3)
      ) {
        labels.push({ name: monthName, weekIndex: i });
      }
      currentWeekStart = addDays(currentWeekStart, 7);
    }
    return labels;
  }, [startDate, weeks]);

  return (
    <div
      className="no-js-visible p-2 overflow-x-auto overflow-y-hidden no-scrollbar [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
    >
      <div className="flex w-max">
        <div className="relative">
          <div className="relative h-4 w-full mb-2">
            {monthLabelsData.map((month, index) => (
              <span
                key={index}
                className="text-xs text-muted-foreground absolute whitespace-nowrap"
                style={{
                  left: `calc(${month.weekIndex} * (0.75rem + 0.125rem))`,
                }}
              >
                {month.name}
              </span>
            ))}
          </div>
          <div className="flex gap-0.5">
            {weeksData.map((week) => (
              <div key={week.weekIndex} className="flex flex-col gap-1">
                {week.days.map((day) => (
                  <Tooltip key={day.key}>
                    <TooltipTrigger asChild>
                      <div
                        className="w-3 h-3 rounded-xs"
                        style={{ backgroundColor: day.color }}
                      />
                    </TooltipTrigger>
                    <TooltipContent>
                      <p>
                        {day.formattedDate}: {day.count} contributions
                      </p>
                    </TooltipContent>
                  </Tooltip>
                ))}
              </div>
            ))}
          </div>
        </div>
      </div>
      <div className="mt-2 justify-center flex gap-1 text-[10px] text-gray-400 items-center">
        <span>less</span>

        {colors.map((color, index) => (
          <div
            key={index}
            className="w-2 h-2 rounded-[1px]"
            style={{ backgroundColor: color }}
          />
        ))}
        <span>more</span>
      </div>
    </div>
  );
};

export { GitHubCalendar };