All guides

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.

example.tsx
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.

example.tsx
// 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.

example.tsx
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.

example.tsx
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 })));