How to Make a GitHub-Style Contribution Graph in React
By Hizmedia · · 6 min read
The contribution graph is one of the most recognisable charts on the web: a year of days as small squares, darker where there was more activity. It works for commits, but just as well for sales, workouts or habits. This guide covers the details behind MotionKit's Contribution Graph, most of which are about dates rather than drawing.
The data
Keep the input simple: one entry per day, oldest first, with an ISO date and a count. Everything else (weeks, months, colours) can be worked out from that.
type ContributionDay = { date: string; count: number };Days into week columns
Each column is a week and each row a weekday. The catch is the first column: if the data doesn't start on the first day of the week, the first day sits partway down. Work out that offset once, then every day's position follows from its index.
// read dates as UTC, so no day shifts with the visitor's time zone
const parse = (date: string) => new Date(`${date}T00:00:00Z`);
const offset = (parse(days[0].date).getUTCDay() + 6) % 7; // weeks start on Monday
const column = (index: number) => Math.floor((index + offset) / 7);
const row = (index: number) => (index + offset) % 7;Parsing dates as UTC matters more than it looks. new Date("2026-03-14") is midnight UTC, which is the previous evening in the Americas, so without it every square can land one day early for some visitors.
Colour levels
Five shades read well: empty, then four steps up to your colour. Map each count to a level by its share of the busiest day, and mix the shades from your colour into the card's background so they work in any palette.
const level = (count: number) => (count === 0 ? 0 : Math.max(1, Math.ceil((count / max) * 4)));
const fill = (value: number) =>
value === 0 ? "rgb(255 255 255 / 0.06)" : `color-mix(in oklab, ${color} ${[0, 30, 55, 78, 100][value]}%, #141B1B)`;Month labels and the fill-in animation
Put each month's label above the week in which its first day falls, and drop any label that would sit within a couple of columns of the previous one, otherwise short months collide. For the entrance, give every square a delay based on its column and row and let it fade and grow in when the graph scrolls into view: the year fills from left to right like a wave.
Tooltips and keyboard support
Hundreds of focusable squares would be miserable to tab through. Make the grid a single focusable group instead: the arrow keys move a day or a week at a time, Home and End jump to the ends, and the active day's count and date appear in a tooltip and are read out through a live region. The pointer drives the same active day, so both paths share one tooltip.
Real GitHub data
GitHub's GraphQL API returns the calendar directly. Request it on the server with a token, so the token never reaches the browser, then flatten the weeks into days.
const query = `query($login: String!) {
user(login: $login) {
contributionsCollection {
contributionCalendar { weeks { contributionDays { date contributionCount } } }
}
}
}`;
// POST it to https://api.github.com/graphql with your token, then:
const days = weeks.flatMap((week) => week.contributionDays.map((day) => ({ date: day.date, count: day.contributionCount })));