shadimage
akoder
uigithub-mapsource: ok · hit

GitHub Heatmap

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

npx shadcn add "https://akoder.xyz/r/github-map.json"
free

Interaction

tooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Registry dependencies

tooltip

Files1 · source cached 10/9/2026, 11:28:41 AM

components/ui/github-map.tsx
"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 };

More from akoder

Browse all components in this library →