Contribution Graph
betaA GitHub-style year heatmap of daily counts — a keyboard-navigable grid with a tooltip per day.
| Weekday | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec | |||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Sunday | No contributions, Sunday, January 5, 2025 | 7 contributions, Sunday, January 12, 2025 | 10 contributions, Sunday, January 19, 2025 | No contributions, Sunday, January 26, 2025 | No contributions, Sunday, February 2, 2025 | 18 contributions, Sunday, February 9, 2025 | 7 contributions, Sunday, February 16, 2025 | No contributions, Sunday, February 23, 2025 | No contributions, Sunday, March 2, 2025 | No contributions, Sunday, March 9, 2025 | No contributions, Sunday, March 16, 2025 | No contributions, Sunday, March 23, 2025 | 7 contributions, Sunday, March 30, 2025 | 19 contributions, Sunday, April 6, 2025 | No contributions, Sunday, April 13, 2025 | No contributions, Sunday, April 20, 2025 | No contributions, Sunday, April 27, 2025 | No contributions, Sunday, May 4, 2025 | No contributions, Sunday, May 11, 2025 | No contributions, Sunday, May 18, 2025 | No contributions, Sunday, May 25, 2025 | No contributions, Sunday, June 1, 2025 | 15 contributions, Sunday, June 8, 2025 | No contributions, Sunday, June 15, 2025 | No contributions, Sunday, June 22, 2025 | 18 contributions, Sunday, June 29, 2025 | No contributions, Sunday, July 6, 2025 | No contributions, Sunday, July 13, 2025 | No contributions, Sunday, July 20, 2025 | No contributions, Sunday, July 27, 2025 | No contributions, Sunday, August 3, 2025 | No contributions, Sunday, August 10, 2025 | No contributions, Sunday, August 17, 2025 | No contributions, Sunday, August 24, 2025 | No contributions, Sunday, August 31, 2025 | 13 contributions, Sunday, September 7, 2025 | 16 contributions, Sunday, September 14, 2025 | No contributions, Sunday, September 21, 2025 | 11 contributions, Sunday, September 28, 2025 | No contributions, Sunday, October 5, 2025 | No contributions, Sunday, October 12, 2025 | 2 contributions, Sunday, October 19, 2025 | 3 contributions, Sunday, October 26, 2025 | No contributions, Sunday, November 2, 2025 | No contributions, Sunday, November 9, 2025 | 1 contribution, Sunday, November 16, 2025 | 1 contribution, Sunday, November 23, 2025 | 15 contributions, Sunday, November 30, 2025 | No contributions, Sunday, December 7, 2025 | No contributions, Sunday, December 14, 2025 | No contributions, Sunday, December 21, 2025 | No contributions, Sunday, December 28, 2025 | |
| Monday | No contributions, Monday, January 6, 2025 | No contributions, Monday, January 13, 2025 | No contributions, Monday, January 20, 2025 | No contributions, Monday, January 27, 2025 | No contributions, Monday, February 3, 2025 | 7 contributions, Monday, February 10, 2025 | No contributions, Monday, February 17, 2025 | No contributions, Monday, February 24, 2025 | No contributions, Monday, March 3, 2025 | 16 contributions, Monday, March 10, 2025 | 18 contributions, Monday, March 17, 2025 | 1 contribution, Monday, March 24, 2025 | No contributions, Monday, March 31, 2025 | No contributions, Monday, April 7, 2025 | No contributions, Monday, April 14, 2025 | No contributions, Monday, April 21, 2025 | 8 contributions, Monday, April 28, 2025 | No contributions, Monday, May 5, 2025 | No contributions, Monday, May 12, 2025 | 8 contributions, Monday, May 19, 2025 | No contributions, Monday, May 26, 2025 | 15 contributions, Monday, June 2, 2025 | No contributions, Monday, June 9, 2025 | No contributions, Monday, June 16, 2025 | No contributions, Monday, June 23, 2025 | No contributions, Monday, June 30, 2025 | No contributions, Monday, July 7, 2025 | No contributions, Monday, July 14, 2025 | No contributions, Monday, July 21, 2025 | No contributions, Monday, July 28, 2025 | No contributions, Monday, August 4, 2025 | No contributions, Monday, August 11, 2025 | No contributions, Monday, August 18, 2025 | 13 contributions, Monday, August 25, 2025 | No contributions, Monday, September 1, 2025 | No contributions, Monday, September 8, 2025 | No contributions, Monday, September 15, 2025 | No contributions, Monday, September 22, 2025 | 4 contributions, Monday, September 29, 2025 | No contributions, Monday, October 6, 2025 | No contributions, Monday, October 13, 2025 | No contributions, Monday, October 20, 2025 | 9 contributions, Monday, October 27, 2025 | No contributions, Monday, November 3, 2025 | No contributions, Monday, November 10, 2025 | No contributions, Monday, November 17, 2025 | 7 contributions, Monday, November 24, 2025 | No contributions, Monday, December 1, 2025 | 3 contributions, Monday, December 8, 2025 | 5 contributions, Monday, December 15, 2025 | No contributions, Monday, December 22, 2025 | 16 contributions, Monday, December 29, 2025 | |
| Tuesday | No contributions, Tuesday, January 7, 2025 | No contributions, Tuesday, January 14, 2025 | No contributions, Tuesday, January 21, 2025 | No contributions, Tuesday, January 28, 2025 | No contributions, Tuesday, February 4, 2025 | No contributions, Tuesday, February 11, 2025 | No contributions, Tuesday, February 18, 2025 | No contributions, Tuesday, February 25, 2025 | 11 contributions, Tuesday, March 4, 2025 | 8 contributions, Tuesday, March 11, 2025 | No contributions, Tuesday, March 18, 2025 | 12 contributions, Tuesday, March 25, 2025 | 9 contributions, Tuesday, April 1, 2025 | No contributions, Tuesday, April 8, 2025 | 2 contributions, Tuesday, April 15, 2025 | No contributions, Tuesday, April 22, 2025 | No contributions, Tuesday, April 29, 2025 | No contributions, Tuesday, May 6, 2025 | 12 contributions, Tuesday, May 13, 2025 | 13 contributions, Tuesday, May 20, 2025 | 5 contributions, Tuesday, May 27, 2025 | No contributions, Tuesday, June 3, 2025 | No contributions, Tuesday, June 10, 2025 | 2 contributions, Tuesday, June 17, 2025 | No contributions, Tuesday, June 24, 2025 | 4 contributions, Tuesday, July 1, 2025 | 8 contributions, Tuesday, July 8, 2025 | No contributions, Tuesday, July 15, 2025 | No contributions, Tuesday, July 22, 2025 | No contributions, Tuesday, July 29, 2025 | No contributions, Tuesday, August 5, 2025 | No contributions, Tuesday, August 12, 2025 | No contributions, Tuesday, August 19, 2025 | No contributions, Tuesday, August 26, 2025 | No contributions, Tuesday, September 2, 2025 | 4 contributions, Tuesday, September 9, 2025 | No contributions, Tuesday, September 16, 2025 | No contributions, Tuesday, September 23, 2025 | No contributions, Tuesday, September 30, 2025 | No contributions, Tuesday, October 7, 2025 | No contributions, Tuesday, October 14, 2025 | No contributions, Tuesday, October 21, 2025 | No contributions, Tuesday, October 28, 2025 | No contributions, Tuesday, November 4, 2025 | No contributions, Tuesday, November 11, 2025 | No contributions, Tuesday, November 18, 2025 | No contributions, Tuesday, November 25, 2025 | No contributions, Tuesday, December 2, 2025 | No contributions, Tuesday, December 9, 2025 | No contributions, Tuesday, December 16, 2025 | No contributions, Tuesday, December 23, 2025 | No contributions, Tuesday, December 30, 2025 | |
| Wednesday | 18 contributions, Wednesday, January 1, 2025 | No contributions, Wednesday, January 8, 2025 | 13 contributions, Wednesday, January 15, 2025 | No contributions, Wednesday, January 22, 2025 | No contributions, Wednesday, January 29, 2025 | 5 contributions, Wednesday, February 5, 2025 | No contributions, Wednesday, February 12, 2025 | No contributions, Wednesday, February 19, 2025 | No contributions, Wednesday, February 26, 2025 | No contributions, Wednesday, March 5, 2025 | 1 contribution, Wednesday, March 12, 2025 | No contributions, Wednesday, March 19, 2025 | 14 contributions, Wednesday, March 26, 2025 | 18 contributions, Wednesday, April 2, 2025 | 4 contributions, Wednesday, April 9, 2025 | No contributions, Wednesday, April 16, 2025 | No contributions, Wednesday, April 23, 2025 | No contributions, Wednesday, April 30, 2025 | 6 contributions, Wednesday, May 7, 2025 | 9 contributions, Wednesday, May 14, 2025 | No contributions, Wednesday, May 21, 2025 | No contributions, Wednesday, May 28, 2025 | No contributions, Wednesday, June 4, 2025 | No contributions, Wednesday, June 11, 2025 | 13 contributions, Wednesday, June 18, 2025 | No contributions, Wednesday, June 25, 2025 | No contributions, Wednesday, July 2, 2025 | No contributions, Wednesday, July 9, 2025 | No contributions, Wednesday, July 16, 2025 | No contributions, Wednesday, July 23, 2025 | No contributions, Wednesday, July 30, 2025 | No contributions, Wednesday, August 6, 2025 | No contributions, Wednesday, August 13, 2025 | No contributions, Wednesday, August 20, 2025 | No contributions, Wednesday, August 27, 2025 | 6 contributions, Wednesday, September 3, 2025 | No contributions, Wednesday, September 10, 2025 | 6 contributions, Wednesday, September 17, 2025 | No contributions, Wednesday, September 24, 2025 | No contributions, Wednesday, October 1, 2025 | No contributions, Wednesday, October 8, 2025 | No contributions, Wednesday, October 15, 2025 | 1 contribution, Wednesday, October 22, 2025 | 6 contributions, Wednesday, October 29, 2025 | No contributions, Wednesday, November 5, 2025 | No contributions, Wednesday, November 12, 2025 | No contributions, Wednesday, November 19, 2025 | 12 contributions, Wednesday, November 26, 2025 | No contributions, Wednesday, December 3, 2025 | No contributions, Wednesday, December 10, 2025 | No contributions, Wednesday, December 17, 2025 | No contributions, Wednesday, December 24, 2025 | No contributions, Wednesday, December 31, 2025 |
| Thursday | 4 contributions, Thursday, January 2, 2025 | No contributions, Thursday, January 9, 2025 | No contributions, Thursday, January 16, 2025 | No contributions, Thursday, January 23, 2025 | 5 contributions, Thursday, January 30, 2025 | No contributions, Thursday, February 6, 2025 | No contributions, Thursday, February 13, 2025 | No contributions, Thursday, February 20, 2025 | No contributions, Thursday, February 27, 2025 | No contributions, Thursday, March 6, 2025 | No contributions, Thursday, March 13, 2025 | No contributions, Thursday, March 20, 2025 | No contributions, Thursday, March 27, 2025 | No contributions, Thursday, April 3, 2025 | 11 contributions, Thursday, April 10, 2025 | 13 contributions, Thursday, April 17, 2025 | No contributions, Thursday, April 24, 2025 | No contributions, Thursday, May 1, 2025 | No contributions, Thursday, May 8, 2025 | No contributions, Thursday, May 15, 2025 | No contributions, Thursday, May 22, 2025 | 14 contributions, Thursday, May 29, 2025 | No contributions, Thursday, June 5, 2025 | 2 contributions, Thursday, June 12, 2025 | 9 contributions, Thursday, June 19, 2025 | 4 contributions, Thursday, June 26, 2025 | 1 contribution, Thursday, July 3, 2025 | No contributions, Thursday, July 10, 2025 | No contributions, Thursday, July 17, 2025 | No contributions, Thursday, July 24, 2025 | No contributions, Thursday, July 31, 2025 | No contributions, Thursday, August 7, 2025 | 4 contributions, Thursday, August 14, 2025 | No contributions, Thursday, August 21, 2025 | 11 contributions, Thursday, August 28, 2025 | No contributions, Thursday, September 4, 2025 | No contributions, Thursday, September 11, 2025 | No contributions, Thursday, September 18, 2025 | No contributions, Thursday, September 25, 2025 | No contributions, Thursday, October 2, 2025 | 18 contributions, Thursday, October 9, 2025 | No contributions, Thursday, October 16, 2025 | 18 contributions, Thursday, October 23, 2025 | No contributions, Thursday, October 30, 2025 | No contributions, Thursday, November 6, 2025 | No contributions, Thursday, November 13, 2025 | No contributions, Thursday, November 20, 2025 | 1 contribution, Thursday, November 27, 2025 | No contributions, Thursday, December 4, 2025 | 17 contributions, Thursday, December 11, 2025 | No contributions, Thursday, December 18, 2025 | No contributions, Thursday, December 25, 2025 | |
| Friday | No contributions, Friday, January 3, 2025 | No contributions, Friday, January 10, 2025 | No contributions, Friday, January 17, 2025 | No contributions, Friday, January 24, 2025 | No contributions, Friday, January 31, 2025 | No contributions, Friday, February 7, 2025 | No contributions, Friday, February 14, 2025 | 18 contributions, Friday, February 21, 2025 | No contributions, Friday, February 28, 2025 | No contributions, Friday, March 7, 2025 | 12 contributions, Friday, March 14, 2025 | No contributions, Friday, March 21, 2025 | No contributions, Friday, March 28, 2025 | No contributions, Friday, April 4, 2025 | 15 contributions, Friday, April 11, 2025 | No contributions, Friday, April 18, 2025 | 14 contributions, Friday, April 25, 2025 | No contributions, Friday, May 2, 2025 | No contributions, Friday, May 9, 2025 | No contributions, Friday, May 16, 2025 | No contributions, Friday, May 23, 2025 | 4 contributions, Friday, May 30, 2025 | No contributions, Friday, June 6, 2025 | 2 contributions, Friday, June 13, 2025 | No contributions, Friday, June 20, 2025 | No contributions, Friday, June 27, 2025 | 12 contributions, Friday, July 4, 2025 | No contributions, Friday, July 11, 2025 | No contributions, Friday, July 18, 2025 | No contributions, Friday, July 25, 2025 | No contributions, Friday, August 1, 2025 | 6 contributions, Friday, August 8, 2025 | No contributions, Friday, August 15, 2025 | No contributions, Friday, August 22, 2025 | 17 contributions, Friday, August 29, 2025 | 2 contributions, Friday, September 5, 2025 | 2 contributions, Friday, September 12, 2025 | No contributions, Friday, September 19, 2025 | No contributions, Friday, September 26, 2025 | 18 contributions, Friday, October 3, 2025 | No contributions, Friday, October 10, 2025 | No contributions, Friday, October 17, 2025 | No contributions, Friday, October 24, 2025 | 1 contribution, Friday, October 31, 2025 | No contributions, Friday, November 7, 2025 | No contributions, Friday, November 14, 2025 | No contributions, Friday, November 21, 2025 | 16 contributions, Friday, November 28, 2025 | 19 contributions, Friday, December 5, 2025 | No contributions, Friday, December 12, 2025 | No contributions, Friday, December 19, 2025 | 14 contributions, Friday, December 26, 2025 | |
| Saturday | No contributions, Saturday, January 4, 2025 | No contributions, Saturday, January 11, 2025 | No contributions, Saturday, January 18, 2025 | No contributions, Saturday, January 25, 2025 | 18 contributions, Saturday, February 1, 2025 | No contributions, Saturday, February 8, 2025 | 8 contributions, Saturday, February 15, 2025 | No contributions, Saturday, February 22, 2025 | No contributions, Saturday, March 1, 2025 | No contributions, Saturday, March 8, 2025 | 9 contributions, Saturday, March 15, 2025 | No contributions, Saturday, March 22, 2025 | 17 contributions, Saturday, March 29, 2025 | 18 contributions, Saturday, April 5, 2025 | No contributions, Saturday, April 12, 2025 | 17 contributions, Saturday, April 19, 2025 | No contributions, Saturday, April 26, 2025 | No contributions, Saturday, May 3, 2025 | No contributions, Saturday, May 10, 2025 | No contributions, Saturday, May 17, 2025 | No contributions, Saturday, May 24, 2025 | No contributions, Saturday, May 31, 2025 | No contributions, Saturday, June 7, 2025 | No contributions, Saturday, June 14, 2025 | No contributions, Saturday, June 21, 2025 | No contributions, Saturday, June 28, 2025 | No contributions, Saturday, July 5, 2025 | 4 contributions, Saturday, July 12, 2025 | No contributions, Saturday, July 19, 2025 | No contributions, Saturday, July 26, 2025 | No contributions, Saturday, August 2, 2025 | No contributions, Saturday, August 9, 2025 | 18 contributions, Saturday, August 16, 2025 | No contributions, Saturday, August 23, 2025 | No contributions, Saturday, August 30, 2025 | No contributions, Saturday, September 6, 2025 | No contributions, Saturday, September 13, 2025 | No contributions, Saturday, September 20, 2025 | No contributions, Saturday, September 27, 2025 | 13 contributions, Saturday, October 4, 2025 | No contributions, Saturday, October 11, 2025 | 14 contributions, Saturday, October 18, 2025 | No contributions, Saturday, October 25, 2025 | 1 contribution, Saturday, November 1, 2025 | No contributions, Saturday, November 8, 2025 | No contributions, Saturday, November 15, 2025 | No contributions, Saturday, November 22, 2025 | No contributions, Saturday, November 29, 2025 | 12 contributions, Saturday, December 6, 2025 | No contributions, Saturday, December 13, 2025 | No contributions, Saturday, December 20, 2025 | No contributions, Saturday, December 27, 2025 | |
Installation
pnpm dlx @dowel-ui/cli add contribution-graphInstalls 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
| Prop | Type | Default |
|---|---|---|
dataOne entry per day with activity. Missing days count as zero. | ContributionDay[] | [] |
formatCountThe text for one day, used as its accessible name and in the tooltip. | (count: number) => string | defaultCount |
getLevelMaps a count to a level 0–4. Defaults to quarters of the year's busiest day. | (count: number, max: number) => number | defaultLevel |
labelThe grid's accessible name. Defaults to "N contributions in YEAR". | string | — |
lessLabel | string | "Less" |
localesLocale for month, weekday and date names. | Intl.LocalesArgument | — |
moreLabel | string | "More" |
showLegendShow the Less → More legend. | boolean | true |
showTooltipsShow a tooltip for the hovered or focused day. | boolean | true |
yearThe calendar year shown. Defaults to the current year. | number | new Date().getUTCFullYear() |
Plus every attribute of <div> except children.
Quality
9/9 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Source
This is exactly what dowel add contribution-graph writes into your project, with imports rewritten to your own path alias.
"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 };