Contribution Graph

beta

A GitHub-style year heatmap of daily counts — a keyboard-navigable grid with a tooltip per day.

968 contributions in 2025
WeekdayJanFebMarAprMayJunJulAugSepOctNovDec
SundayNo contributions, Sunday, January 5, 20257 contributions, Sunday, January 12, 202510 contributions, Sunday, January 19, 2025No contributions, Sunday, January 26, 2025No contributions, Sunday, February 2, 202518 contributions, Sunday, February 9, 20257 contributions, Sunday, February 16, 2025No contributions, Sunday, February 23, 2025No contributions, Sunday, March 2, 2025No contributions, Sunday, March 9, 2025No contributions, Sunday, March 16, 2025No contributions, Sunday, March 23, 20257 contributions, Sunday, March 30, 202519 contributions, Sunday, April 6, 2025No contributions, Sunday, April 13, 2025No contributions, Sunday, April 20, 2025No contributions, Sunday, April 27, 2025No contributions, Sunday, May 4, 2025No contributions, Sunday, May 11, 2025No contributions, Sunday, May 18, 2025No contributions, Sunday, May 25, 2025No contributions, Sunday, June 1, 202515 contributions, Sunday, June 8, 2025No contributions, Sunday, June 15, 2025No contributions, Sunday, June 22, 202518 contributions, Sunday, June 29, 2025No contributions, Sunday, July 6, 2025No contributions, Sunday, July 13, 2025No contributions, Sunday, July 20, 2025No contributions, Sunday, July 27, 2025No contributions, Sunday, August 3, 2025No contributions, Sunday, August 10, 2025No contributions, Sunday, August 17, 2025No contributions, Sunday, August 24, 2025No contributions, Sunday, August 31, 202513 contributions, Sunday, September 7, 202516 contributions, Sunday, September 14, 2025No contributions, Sunday, September 21, 202511 contributions, Sunday, September 28, 2025No contributions, Sunday, October 5, 2025No contributions, Sunday, October 12, 20252 contributions, Sunday, October 19, 20253 contributions, Sunday, October 26, 2025No contributions, Sunday, November 2, 2025No contributions, Sunday, November 9, 20251 contribution, Sunday, November 16, 20251 contribution, Sunday, November 23, 202515 contributions, Sunday, November 30, 2025No contributions, Sunday, December 7, 2025No contributions, Sunday, December 14, 2025No contributions, Sunday, December 21, 2025No contributions, Sunday, December 28, 2025
MondayNo contributions, Monday, January 6, 2025No contributions, Monday, January 13, 2025No contributions, Monday, January 20, 2025No contributions, Monday, January 27, 2025No contributions, Monday, February 3, 20257 contributions, Monday, February 10, 2025No contributions, Monday, February 17, 2025No contributions, Monday, February 24, 2025No contributions, Monday, March 3, 202516 contributions, Monday, March 10, 202518 contributions, Monday, March 17, 20251 contribution, Monday, March 24, 2025No contributions, Monday, March 31, 2025No contributions, Monday, April 7, 2025No contributions, Monday, April 14, 2025No contributions, Monday, April 21, 20258 contributions, Monday, April 28, 2025No contributions, Monday, May 5, 2025No contributions, Monday, May 12, 20258 contributions, Monday, May 19, 2025No contributions, Monday, May 26, 202515 contributions, Monday, June 2, 2025No contributions, Monday, June 9, 2025No contributions, Monday, June 16, 2025No contributions, Monday, June 23, 2025No contributions, Monday, June 30, 2025No contributions, Monday, July 7, 2025No contributions, Monday, July 14, 2025No contributions, Monday, July 21, 2025No contributions, Monday, July 28, 2025No contributions, Monday, August 4, 2025No contributions, Monday, August 11, 2025No contributions, Monday, August 18, 202513 contributions, Monday, August 25, 2025No contributions, Monday, September 1, 2025No contributions, Monday, September 8, 2025No contributions, Monday, September 15, 2025No contributions, Monday, September 22, 20254 contributions, Monday, September 29, 2025No contributions, Monday, October 6, 2025No contributions, Monday, October 13, 2025No contributions, Monday, October 20, 20259 contributions, Monday, October 27, 2025No contributions, Monday, November 3, 2025No contributions, Monday, November 10, 2025No contributions, Monday, November 17, 20257 contributions, Monday, November 24, 2025No contributions, Monday, December 1, 20253 contributions, Monday, December 8, 20255 contributions, Monday, December 15, 2025No contributions, Monday, December 22, 202516 contributions, Monday, December 29, 2025
TuesdayNo contributions, Tuesday, January 7, 2025No contributions, Tuesday, January 14, 2025No contributions, Tuesday, January 21, 2025No contributions, Tuesday, January 28, 2025No contributions, Tuesday, February 4, 2025No contributions, Tuesday, February 11, 2025No contributions, Tuesday, February 18, 2025No contributions, Tuesday, February 25, 202511 contributions, Tuesday, March 4, 20258 contributions, Tuesday, March 11, 2025No contributions, Tuesday, March 18, 202512 contributions, Tuesday, March 25, 20259 contributions, Tuesday, April 1, 2025No contributions, Tuesday, April 8, 20252 contributions, Tuesday, April 15, 2025No contributions, Tuesday, April 22, 2025No contributions, Tuesday, April 29, 2025No contributions, Tuesday, May 6, 202512 contributions, Tuesday, May 13, 202513 contributions, Tuesday, May 20, 20255 contributions, Tuesday, May 27, 2025No contributions, Tuesday, June 3, 2025No contributions, Tuesday, June 10, 20252 contributions, Tuesday, June 17, 2025No contributions, Tuesday, June 24, 20254 contributions, Tuesday, July 1, 20258 contributions, Tuesday, July 8, 2025No contributions, Tuesday, July 15, 2025No contributions, Tuesday, July 22, 2025No contributions, Tuesday, July 29, 2025No contributions, Tuesday, August 5, 2025No contributions, Tuesday, August 12, 2025No contributions, Tuesday, August 19, 2025No contributions, Tuesday, August 26, 2025No contributions, Tuesday, September 2, 20254 contributions, Tuesday, September 9, 2025No contributions, Tuesday, September 16, 2025No contributions, Tuesday, September 23, 2025No contributions, Tuesday, September 30, 2025No contributions, Tuesday, October 7, 2025No contributions, Tuesday, October 14, 2025No contributions, Tuesday, October 21, 2025No contributions, Tuesday, October 28, 2025No contributions, Tuesday, November 4, 2025No contributions, Tuesday, November 11, 2025No contributions, Tuesday, November 18, 2025No contributions, Tuesday, November 25, 2025No contributions, Tuesday, December 2, 2025No contributions, Tuesday, December 9, 2025No contributions, Tuesday, December 16, 2025No contributions, Tuesday, December 23, 2025No contributions, Tuesday, December 30, 2025
Wednesday18 contributions, Wednesday, January 1, 2025No contributions, Wednesday, January 8, 202513 contributions, Wednesday, January 15, 2025No contributions, Wednesday, January 22, 2025No contributions, Wednesday, January 29, 20255 contributions, Wednesday, February 5, 2025No contributions, Wednesday, February 12, 2025No contributions, Wednesday, February 19, 2025No contributions, Wednesday, February 26, 2025No contributions, Wednesday, March 5, 20251 contribution, Wednesday, March 12, 2025No contributions, Wednesday, March 19, 202514 contributions, Wednesday, March 26, 202518 contributions, Wednesday, April 2, 20254 contributions, Wednesday, April 9, 2025No contributions, Wednesday, April 16, 2025No contributions, Wednesday, April 23, 2025No contributions, Wednesday, April 30, 20256 contributions, Wednesday, May 7, 20259 contributions, Wednesday, May 14, 2025No contributions, Wednesday, May 21, 2025No contributions, Wednesday, May 28, 2025No contributions, Wednesday, June 4, 2025No contributions, Wednesday, June 11, 202513 contributions, Wednesday, June 18, 2025No contributions, Wednesday, June 25, 2025No contributions, Wednesday, July 2, 2025No contributions, Wednesday, July 9, 2025No contributions, Wednesday, July 16, 2025No contributions, Wednesday, July 23, 2025No contributions, Wednesday, July 30, 2025No contributions, Wednesday, August 6, 2025No contributions, Wednesday, August 13, 2025No contributions, Wednesday, August 20, 2025No contributions, Wednesday, August 27, 20256 contributions, Wednesday, September 3, 2025No contributions, Wednesday, September 10, 20256 contributions, Wednesday, September 17, 2025No contributions, Wednesday, September 24, 2025No contributions, Wednesday, October 1, 2025No contributions, Wednesday, October 8, 2025No contributions, Wednesday, October 15, 20251 contribution, Wednesday, October 22, 20256 contributions, Wednesday, October 29, 2025No contributions, Wednesday, November 5, 2025No contributions, Wednesday, November 12, 2025No contributions, Wednesday, November 19, 202512 contributions, Wednesday, November 26, 2025No contributions, Wednesday, December 3, 2025No contributions, Wednesday, December 10, 2025No contributions, Wednesday, December 17, 2025No contributions, Wednesday, December 24, 2025No contributions, Wednesday, December 31, 2025
Thursday4 contributions, Thursday, January 2, 2025No contributions, Thursday, January 9, 2025No contributions, Thursday, January 16, 2025No contributions, Thursday, January 23, 20255 contributions, Thursday, January 30, 2025No contributions, Thursday, February 6, 2025No contributions, Thursday, February 13, 2025No contributions, Thursday, February 20, 2025No contributions, Thursday, February 27, 2025No contributions, Thursday, March 6, 2025No contributions, Thursday, March 13, 2025No contributions, Thursday, March 20, 2025No contributions, Thursday, March 27, 2025No contributions, Thursday, April 3, 202511 contributions, Thursday, April 10, 202513 contributions, Thursday, April 17, 2025No contributions, Thursday, April 24, 2025No contributions, Thursday, May 1, 2025No contributions, Thursday, May 8, 2025No contributions, Thursday, May 15, 2025No contributions, Thursday, May 22, 202514 contributions, Thursday, May 29, 2025No contributions, Thursday, June 5, 20252 contributions, Thursday, June 12, 20259 contributions, Thursday, June 19, 20254 contributions, Thursday, June 26, 20251 contribution, Thursday, July 3, 2025No contributions, Thursday, July 10, 2025No contributions, Thursday, July 17, 2025No contributions, Thursday, July 24, 2025No contributions, Thursday, July 31, 2025No contributions, Thursday, August 7, 20254 contributions, Thursday, August 14, 2025No contributions, Thursday, August 21, 202511 contributions, Thursday, August 28, 2025No contributions, Thursday, September 4, 2025No contributions, Thursday, September 11, 2025No contributions, Thursday, September 18, 2025No contributions, Thursday, September 25, 2025No contributions, Thursday, October 2, 202518 contributions, Thursday, October 9, 2025No contributions, Thursday, October 16, 202518 contributions, Thursday, October 23, 2025No contributions, Thursday, October 30, 2025No contributions, Thursday, November 6, 2025No contributions, Thursday, November 13, 2025No contributions, Thursday, November 20, 20251 contribution, Thursday, November 27, 2025No contributions, Thursday, December 4, 202517 contributions, Thursday, December 11, 2025No contributions, Thursday, December 18, 2025No contributions, Thursday, December 25, 2025
FridayNo contributions, Friday, January 3, 2025No contributions, Friday, January 10, 2025No contributions, Friday, January 17, 2025No contributions, Friday, January 24, 2025No contributions, Friday, January 31, 2025No contributions, Friday, February 7, 2025No contributions, Friday, February 14, 202518 contributions, Friday, February 21, 2025No contributions, Friday, February 28, 2025No contributions, Friday, March 7, 202512 contributions, Friday, March 14, 2025No contributions, Friday, March 21, 2025No contributions, Friday, March 28, 2025No contributions, Friday, April 4, 202515 contributions, Friday, April 11, 2025No contributions, Friday, April 18, 202514 contributions, Friday, April 25, 2025No contributions, Friday, May 2, 2025No contributions, Friday, May 9, 2025No contributions, Friday, May 16, 2025No contributions, Friday, May 23, 20254 contributions, Friday, May 30, 2025No contributions, Friday, June 6, 20252 contributions, Friday, June 13, 2025No contributions, Friday, June 20, 2025No contributions, Friday, June 27, 202512 contributions, Friday, July 4, 2025No contributions, Friday, July 11, 2025No contributions, Friday, July 18, 2025No contributions, Friday, July 25, 2025No contributions, Friday, August 1, 20256 contributions, Friday, August 8, 2025No contributions, Friday, August 15, 2025No contributions, Friday, August 22, 202517 contributions, Friday, August 29, 20252 contributions, Friday, September 5, 20252 contributions, Friday, September 12, 2025No contributions, Friday, September 19, 2025No contributions, Friday, September 26, 202518 contributions, Friday, October 3, 2025No contributions, Friday, October 10, 2025No contributions, Friday, October 17, 2025No contributions, Friday, October 24, 20251 contribution, Friday, October 31, 2025No contributions, Friday, November 7, 2025No contributions, Friday, November 14, 2025No contributions, Friday, November 21, 202516 contributions, Friday, November 28, 202519 contributions, Friday, December 5, 2025No contributions, Friday, December 12, 2025No contributions, Friday, December 19, 202514 contributions, Friday, December 26, 2025
SaturdayNo contributions, Saturday, January 4, 2025No contributions, Saturday, January 11, 2025No contributions, Saturday, January 18, 2025No contributions, Saturday, January 25, 202518 contributions, Saturday, February 1, 2025No contributions, Saturday, February 8, 20258 contributions, Saturday, February 15, 2025No contributions, Saturday, February 22, 2025No contributions, Saturday, March 1, 2025No contributions, Saturday, March 8, 20259 contributions, Saturday, March 15, 2025No contributions, Saturday, March 22, 202517 contributions, Saturday, March 29, 202518 contributions, Saturday, April 5, 2025No contributions, Saturday, April 12, 202517 contributions, Saturday, April 19, 2025No contributions, Saturday, April 26, 2025No contributions, Saturday, May 3, 2025No contributions, Saturday, May 10, 2025No contributions, Saturday, May 17, 2025No contributions, Saturday, May 24, 2025No contributions, Saturday, May 31, 2025No contributions, Saturday, June 7, 2025No contributions, Saturday, June 14, 2025No contributions, Saturday, June 21, 2025No contributions, Saturday, June 28, 2025No contributions, Saturday, July 5, 20254 contributions, Saturday, July 12, 2025No contributions, Saturday, July 19, 2025No contributions, Saturday, July 26, 2025No contributions, Saturday, August 2, 2025No contributions, Saturday, August 9, 202518 contributions, Saturday, August 16, 2025No contributions, Saturday, August 23, 2025No contributions, Saturday, August 30, 2025No contributions, Saturday, September 6, 2025No contributions, Saturday, September 13, 2025No contributions, Saturday, September 20, 2025No contributions, Saturday, September 27, 202513 contributions, Saturday, October 4, 2025No contributions, Saturday, October 11, 202514 contributions, Saturday, October 18, 2025No contributions, Saturday, October 25, 20251 contribution, Saturday, November 1, 2025No contributions, Saturday, November 8, 2025No contributions, Saturday, November 15, 2025No contributions, Saturday, November 22, 2025No contributions, Saturday, November 29, 202512 contributions, Saturday, December 6, 2025No contributions, Saturday, December 13, 2025No contributions, Saturday, December 20, 2025No contributions, Saturday, December 27, 2025
LessMore

Installation

Terminal
pnpm dlx @dowel-ui/cli add contribution-graph

Installs tooltip as well, because this component imports it.

npm packages installed: class-variance-authority.

Accessibility

A table with role="grid", named by its caption ("1,234 contributions in 2025" or `label`). Weekdays are row headers and months column headers; every day is a gridcell whose screen-reader text states the count and the full date, so colour never carries the value. One day is tabbable (roving tabindex): arrows move by day and week (mirrored in RTL), Home/End to the ends of the weekday row, Ctrl+Home/End to the ends of the year, PageUp/PageDown by four weeks. The tooltip follows keyboard focus as well as hover and Escape dismisses it.

Props

ContributionGraph

PropTypeDefault
data

One entry per day with activity. Missing days count as zero.

ContributionDay[][]
formatCount

The text for one day, used as its accessible name and in the tooltip.

(count: number) => stringdefaultCount
getLevel

Maps a count to a level 0–4. Defaults to quarters of the year's busiest day.

(count: number, max: number) => numberdefaultLevel
label

The grid's accessible name. Defaults to "N contributions in YEAR".

string
lessLabelstring"Less"
locales

Locale for month, weekday and date names.

Intl.LocalesArgument
moreLabelstring"More"
showLegend

Show the Less → More legend.

booleantrue
showTooltips

Show a tooltip for the hovered or focused day.

booleantrue
year

The calendar year shown. Defaults to the current year.

numbernew Date().getUTCFullYear()

Plus every attribute of <div> except children.

Quality

9/9 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Source

This is exactly what dowel add contribution-graph writes into your project, with imports rewritten to your own path alias.

ui/contribution-graph.tsx
"use client";

// Ported from SmoothUI Contribution Graph (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { cva } from "class-variance-authority";
import {
  useId,
  useMemo,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type KeyboardEvent,
} from "react";

import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip";
import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

/*
 * A GitHub-style year heatmap, as an ARIA grid: weekdays are rows, weeks are
 * columns, and every day is a gridcell whose screen-reader text is the whole
 * fact ("3 contributions on Monday, 6 January 2025"). One cell is in the tab
 * order (roving tabindex); arrow keys move a day or a week — mirrored in RTL,
 * where weeks run the other way — Home/End go to the ends of the weekday's
 * row, Ctrl+Home/End to the ends of the year, PageUp/PageDown by four weeks.
 *
 * There is one Dowel Tooltip, not 371. It is controlled, and its trigger is an
 * invisible anchor moved over whichever day is hovered or focused, so pointer
 * and keyboard users see the same tooltip. Escape dismisses it.
 *
 * Dates are calendar days in UTC throughout: "2025-03-09" is that day wherever
 * the reader is, which the source's local-time arithmetic did not guarantee.
 * Levels are the primary token mixed into muted at 25/50/75/100%.
 */

const DAY = 86_400_000;

export interface ContributionDay {
  /** Calendar date, `YYYY-MM-DD`. */
  date: string;
  count: number;
  /** 0–4. Derived from `count` when omitted. */
  level?: number;
}

const contributionGraphCellVariants = cva("block size-2.5 rounded-[2px]", {
  variants: {
    level: {
      0: "bg-muted",
      1: "bg-[color-mix(in_oklab,var(--color-primary)_25%,var(--color-muted))]",
      2: "bg-[color-mix(in_oklab,var(--color-primary)_50%,var(--color-muted))]",
      3: "bg-[color-mix(in_oklab,var(--color-primary)_75%,var(--color-muted))]",
      4: "bg-primary",
    },
  },
  defaultVariants: { level: 0 },
});

type Level = 0 | 1 | 2 | 3 | 4;
const LEVELS: Level[] = [0, 1, 2, 3, 4];

export interface ContributionGraphProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
  /** One entry per day with activity. Missing days count as zero. */
  data?: ContributionDay[];
  /** The calendar year shown. Defaults to the current year. */
  year?: number;
  /** Locale for month, weekday and date names. */
  locales?: Intl.LocalesArgument;
  /** Show the Less → More legend. */
  showLegend?: boolean;
  /** Show a tooltip for the hovered or focused day. */
  showTooltips?: boolean;
  /** The grid's accessible name. Defaults to "N contributions in YEAR". */
  label?: string;
  /** Maps a count to a level 0–4. Defaults to quarters of the year's busiest day. */
  getLevel?: (count: number, max: number) => number;
  /** The text for one day, used as its accessible name and in the tooltip. */
  formatCount?: (count: number) => string;
  lessLabel?: string;
  moreLabel?: string;
}

function defaultLevel(count: number, max: number): number {
  if (count <= 0 || max <= 0) return 0;
  return Math.min(4, Math.ceil((count / max) * 4));
}

function defaultCount(count: number): string {
  if (count === 0) return "No contributions";
  return `${String(count)} ${count === 1 ? "contribution" : "contributions"}`;
}

const keyOf = (time: number) => new Date(time).toISOString().slice(0, 10);
const weekday = (time: number) => new Date(time).getUTCDay();

function isRtl(element: Element) {
  return element.closest("[dir]")?.getAttribute("dir") === "rtl";
}

/** A year of activity as a keyboard-navigable heatmap grid. */
export function ContributionGraph({
  className,
  data = [],
  year = new Date().getUTCFullYear(),
  locales,
  showLegend = true,
  showTooltips = true,
  label,
  getLevel = defaultLevel,
  formatCount = defaultCount,
  lessLabel = "Less",
  moreLabel = "More",
  ...props
}: ContributionGraphProps) {
  const captionId = useId();
  const table = useRef<HTMLTableElement>(null);
  const surface = useRef<HTMLDivElement>(null);

  const start = Date.UTC(year, 0, 1);
  const end = Date.UTC(year, 11, 31);
  const gridStart = start - weekday(start) * DAY;
  const weeks = Math.round(((end + (6 - weekday(end)) * DAY - gridStart) / DAY + 1) / 7);

  const [active, setActive] = useState(start);
  const current = Math.min(Math.max(active, start), end);
  const [hovered, setHovered] = useState<number | null>(null);
  const [focused, setFocused] = useState(false);
  const [dismissed, setDismissed] = useState(false);
  const [anchor, setAnchor] = useState({ left: 0, top: 0, width: 0, height: 0 });

  const { byDate, max, total } = useMemo(() => {
    const map = new Map<string, ContributionDay>();
    let busiest = 0;
    let sum = 0;
    for (const day of data) {
      if (!day.date.startsWith(String(year))) continue;
      map.set(day.date, day);
      busiest = Math.max(busiest, day.count);
      sum += day.count;
    }
    return { byDate: map, max: busiest, total: sum };
  }, [data, year]);

  const names = useMemo(() => {
    const month = new Intl.DateTimeFormat(locales, { month: "short", timeZone: "UTC" });
    const short = new Intl.DateTimeFormat(locales, { weekday: "short", timeZone: "UTC" });
    const long = new Intl.DateTimeFormat(locales, { weekday: "long", timeZone: "UTC" });
    const full = new Intl.DateTimeFormat(locales, { dateStyle: "full", timeZone: "UTC" });
    // 1 January 2023 was a Sunday.
    const sunday = Date.UTC(2023, 0, 1);
    return {
      month: (time: number) => month.format(time),
      days: Array.from({ length: 7 }, (_, index) => ({
        short: short.format(sunday + index * DAY),
        long: long.format(sunday + index * DAY),
      })),
      full: (time: number) => full.format(time),
    };
  }, [locales]);

  function dayAt(time: number) {
    const entry = byDate.get(keyOf(time));
    const count = entry?.count ?? 0;
    const level = Math.min(
      4,
      Math.max(0, Math.round(entry?.level ?? getLevel(count, max))),
    ) as Level;
    return { count, level, text: `${formatCount(count)}, ${names.full(time)}` };
  }

  // Month headers: consecutive weeks grouped by the month their first in-year day falls in.
  const months: { key: string; name: string; span: number }[] = [];
  for (let week = 0; week < weeks; week++) {
    const first = Math.max(gridStart + week * 7 * DAY, start);
    const month = new Date(first).getUTCMonth();
    const last = months[months.length - 1];
    if (last?.key === String(month)) last.span++;
    else months.push({ key: String(month), name: names.month(first), span: 1 });
  }

  function place(cell: HTMLElement) {
    const box = surface.current?.getBoundingClientRect();
    const rect = cell.getBoundingClientRect();
    setAnchor({
      left: rect.left - (box?.left ?? 0),
      top: rect.top - (box?.top ?? 0),
      width: rect.width,
      height: rect.height,
    });
  }

  function focusDay(time: number) {
    const cell = table.current?.querySelector<HTMLElement>(`[data-date="${keyOf(time)}"]`);
    cell?.focus();
  }

  function handleKeyDown(event: KeyboardEvent<HTMLTableElement>) {
    if (event.key === "Escape") {
      setDismissed(true);
      return;
    }
    const forward = isRtl(event.currentTarget) ? -1 : 1;
    const day = weekday(current);
    const targets: Record<string, number> = {
      ArrowRight: current + 7 * DAY * forward,
      ArrowLeft: current - 7 * DAY * forward,
      ArrowDown: current + DAY,
      ArrowUp: current - DAY,
      PageDown: current + 28 * DAY,
      PageUp: current - 28 * DAY,
      Home: event.ctrlKey ? start : start + ((day - weekday(start) + 7) % 7) * DAY,
      End: event.ctrlKey ? end : end - ((weekday(end) - day + 7) % 7) * DAY,
    };
    const target = targets[event.key];
    if (target === undefined) return;
    event.preventDefault();
    const next = event.key.startsWith("Page") ? Math.min(Math.max(target, start), end) : target;
    if (next < start || next > end) return;
    focusDay(next);
  }

  const shown = hovered ?? (focused ? current : null);
  const open = showTooltips && shown !== null && !dismissed;
  const tooltipDay = shown === null ? null : dayAt(shown);

  return (
    <div
      data-slot="contribution-graph"
      className={cn("flex flex-col gap-3 text-xs", className)}
      {...props}
    >
      <div className="overflow-x-auto">
        <div ref={surface} className="relative w-fit">
          <table
            ref={table}
            role="grid"
            aria-labelledby={captionId}
            className="border-separate border-spacing-[3px]"
            onKeyDown={handleKeyDown}
            onPointerLeave={() => {
              setHovered(null);
            }}
            onFocus={() => {
              setFocused(true);
            }}
            onBlur={(event) => {
              if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
            }}
          >
            <caption id={captionId} className="sr-only">
              {label ??
                `${String(total)} ${total === 1 ? "contribution" : "contributions"} in ${String(year)}`}
            </caption>
            <thead>
              <tr>
                <th scope="col">
                  <span className="sr-only">Weekday</span>
                </th>
                {months.map((month, index) => (
                  <th
                    key={`${month.key}-${String(index)}`}
                    scope="colgroup"
                    colSpan={month.span}
                    className="h-4 p-0 text-start align-bottom font-normal whitespace-nowrap text-muted-foreground"
                  >
                    {month.span > 1 ? (
                      month.name
                    ) : (
                      <span className="sr-only">{month.name}</span>
                    )}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {names.days.map((name, row) => (
                <tr key={name.long}>
                  <th
                    scope="row"
                    className="h-2.5 p-0 pe-1 text-start text-2xs leading-none font-normal text-muted-foreground"
                  >
                    <span aria-hidden="true" className={row % 2 ? "invisible" : undefined}>
                      {name.short}
                    </span>
                    <span className="sr-only">{name.long}</span>
                  </th>
                  {Array.from({ length: weeks }, (_, week) => {
                    const time = gridStart + (week * 7 + row) * DAY;
                    if (time < start || time > end) {
                      return <td key={week} role="gridcell" className="p-0" />;
                    }
                    const day = dayAt(time);
                    const key = keyOf(time);
                    return (
                      <td
                        key={week}
                        role="gridcell"
                        data-date={key}
                        data-level={day.level}
                        tabIndex={time === current ? 0 : -1}
                        className={cn("rounded-[2px] p-0", focusRing)}
                        onFocus={(event) => {
                          setActive(time);
                          setDismissed(false);
                          place(event.currentTarget);
                        }}
                        onPointerEnter={(event) => {
                          setHovered(time);
                          setDismissed(false);
                          place(event.currentTarget);
                        }}
                      >
                        <span
                          aria-hidden="true"
                          className={cn(
                            contributionGraphCellVariants({ level: day.level }),
                            "transition-[scale] duration-[var(--duration-fast)] ease-[var(--ease-out-quint)] hover:scale-125",
                          )}
                        />
                        <span className="sr-only">{day.text}</span>
                      </td>
                    );
                  })}
                </tr>
              ))}
            </tbody>
          </table>
          {showTooltips ? (
            <Tooltip
              open={open}
              onOpenChange={(next) => {
                if (!next) setDismissed(true);
              }}
            >
              <TooltipTrigger asChild>
                <span
                  aria-hidden="true"
                  data-slot="contribution-graph-anchor"
                  className="pointer-events-none absolute"
                  style={anchor}
                />
              </TooltipTrigger>
              {tooltipDay && shown !== null ? (
                <TooltipContent>
                  <span className="block font-semibold">{formatCount(tooltipDay.count)}</span>
                  <span className="block opacity-80">{names.full(shown)}</span>
                </TooltipContent>
              ) : null}
            </Tooltip>
          ) : null}
        </div>
      </div>
      {showLegend ? (
        <div
          data-slot="contribution-graph-legend"
          className="flex items-center justify-end gap-1 text-muted-foreground"
        >
          <span className="me-1">{lessLabel}</span>
          {LEVELS.map((level) => (
            <span
              key={level}
              aria-hidden="true"
              className={contributionGraphCellVariants({ level })}
            />
          ))}
          <span className="ms-1">{moreLabel}</span>
        </div>
      ) : null}
    </div>
  );
}

export { contributionGraphCellVariants };