← Learn · Build · 25 October 2026 · 7 min

6 Ways to Put Your GitHub Graph on Your Portfolio (Animated, With Real Data)

Pull your real GitHub contribution calendar with one GraphQL query and animate it six ways on canvas: a skyline of commits, the grid, a weekly pulse, a year clock, your streak and a terminal. Code and a prompt inside.

The output, liveavivashishta.com/lab/commits
Open the live page ↗

Everyone's portfolio says "I code every day". Your GitHub contribution graph actually proves it, and most people either skip it or paste a screenshot. My GitHub README already had an animated version, and I liked it enough to want it at hero size. So I took the same data and drew it six different ways: it is live at avivashishta.com/lab/commits.

It opens on a skyline, one building per day, each as tall as that day's commits, and then goes through the grid, a weekly pulse, a ring for the year, my current streak and a terminal. All of it is the real calendar: 8,546 contributions, a 169-day streak, and one day with 128.

The short version: one GraphQL query gives you every day of the last year with its count and its shade. A small script saves that to a JSON file, so the page never calls GitHub while someone is waiting. Each animation is one canvas and one function that takes the time since it started and draws that frame. No libraries.

Step 1: get your graph as data

The graph on your profile is not an image you can scrape nicely, but GitHub's GraphQL API hands it over directly. You need a token (any personal access token works, it only reads public profile data) or the GitHub CLI, which already has one:

gh api graphql -f query='{
  user(login: "AviVashishta29") {
    contributionsCollection {
      contributionCalendar {
        totalContributions
        weeks { contributionDays { date contributionCount contributionLevel } }
      }
    }
  }
}'

You get 53 weeks of 7 days, Sunday first. contributionLevel is the shade GitHub itself uses (NONE to FOURTH_QUARTILE), so you do not have to invent your own buckets and your graph will look like your profile.

Two things that caught me:

  • The days don't add up to the total. My days summed to 8,395 while totalContributions said 8,546. Count-ups that add day by day stop short, so I scale the running count to land on GitHub's number.
  • Private work only counts if you allow it. If you commit to private repos, turn on "Include private contributions on my profile" in your GitHub settings, or your graph will look emptier than your year was.

Step 2: save it, don't fetch it live

Calling GitHub on every page view is slow and needs a token on the server. The calendar changes once a day at most, so a script writes it to a file and the page imports that file:

// scripts/fetch-github.mjs
const token = process.env.GITHUB_TOKEN || execSync("gh auth token").toString().trim();
const res = await fetch("https://api.github.com/graphql", {
  method: "POST",
  headers: { Authorization: `bearer ${token}` },
  body: JSON.stringify({ query: QUERY }),
});
const days = (await res.json()).data.user.contributionsCollection.contributionCalendar.weeks.flatMap((w) => w.contributionDays);
writeFileSync("data/github.json", JSON.stringify({
  start: days[0].date,
  counts: days.map((d) => d.contributionCount),
  levels: days.map((d) => LEVEL[d.contributionLevel]).join(""), // "0" to "4", one digit per day
}));

The whole year is about 3 KB. To keep it fresh, run the script from a daily GitHub Action that commits the file, and your host redeploys with the new graph. That is how my README graph stays current.

Step 3: one canvas, one function per animation

Every animation is written the same way: a function that gets the canvas, its size and t, the milliseconds since it started playing, and draws that frame from scratch.

function wave({ ctx, w, h, t }: Frame) {
  for (let i = 0; i < counts.length; i++) {
    const delay = (col(i) + row(i) * 1.6) * 22;         // a diagonal wave
    const p = clamp((t - delay) / 560);                 // this cell's progress, 0 to 1
    if (p <= 0) continue;
    const size = cell * (0.2 + 0.8 * easeBack(p));      // overshoots a little, then settles
    ctx.fillStyle = p < 1 ? mix(BLUE, RAMP[level[i]], p) : RAMP[level[i]];
    ctx.fillRect(/* ... */);
  }
}

This makes everything else free. Replay is resetting the start time. Someone with reduced motion turned on gets t = Infinity, which is the finished frame. And the page plays each one the first time it scrolls into view and only draws while it is on screen, so six canvases cost nothing when you are not looking at them.

The six

1. Skyline. Each day is a box drawn as three shapes: the front, a lit top and a shaded side, offset up and to the right so it reads as 3D with no 3D library. Rows are drawn back to front so nearer buildings cover farther ones. Height is the square root of the count, not the count, or your one huge day makes everything else look flat. Once it has risen, a leader line points at the best day.

2. The grid. The README version at full size. Cells drop in on a diagonal, land solid blue and fade to their real shade, while the big number counts up by exactly what has landed. Hover any square for its date and count.

3. Week by week. The weekly totals draw as one line with a dot riding the front, the same language as my WHOOP dashboard. Then each week pours down from its point on the line into its seven days, and the line stays behind as a faint sparkline.

4. Round the year. The calendar bent into a ring: seven rings for the days of the week, one spoke per week, a hand sweeping the year. Each cell is two arcs joined into a curved tile:

ctx.arc(cx, cy, outer, a0, a1);
ctx.arc(cx, cy, inner, a1, a0, true); // back the other way on the inner ring
ctx.fill();

5. The streak. Everything before the streak fades back. A dot runs through every day of the streak in order with a short fading trail, the cells it passes light up, and a counter goes Day 1, Day 2 up to 169. The last day off gets a small red cross. The streak itself is easy to work out from the counts: walk back from today until you hit a zero (start from yesterday if today has nothing yet).

6. In the terminal. A nod to where the README starts. The command types itself, then every day shows a random glyph for a moment and settles into one of five characters, . : + # @, from least to most commits. A summary line types out at the end.

The prompt

If you want one of these on your own site, give your AI coding tool this. Swap in your username and pick which animations you want.

Add an animated GitHub contribution graph to my portfolio using my real data. GitHub username: YOUR_USERNAME.

Data: write scripts/fetch-github.mjs that calls the GitHub GraphQL API (user.contributionsCollection.contributionCalendar: totalContributions, weeks.contributionDays with date, contributionCount, contributionLevel) using GITHUB_TOKEN or `gh auth token`, and writes data/github.json with the start date, the total, an array of daily counts and a string of levels 0 to 4. The page imports that file; never call GitHub at request time.

Rendering: one client component per animation, each a canvas that fills its box at devicePixelRatio (max 2). Write each animation as a pure function draw(ctx, w, h, t) where t is milliseconds since it started, so replay resets t and prefers-reduced-motion draws t = Infinity. Start playing when the canvas is 40% in view (IntersectionObserver) and only run requestAnimationFrame while visible. Add a small replay button.

Animations: (1) skyline: each day a box with front, top and side faces in an oblique projection, height by the square root of the count, rising in a wave front row to back, then a leader line to the best day. (2) grid: cells drop in on a diagonal wave with a slight overshoot while a big number counts up to the total, with a hover tooltip for each day. (3) pulse: weekly totals as a line with a riding dot, then each week's seven cells pour down from the line into the grid. (4) ring: seven rings for weekdays, one spoke per week, revealed by a sweeping hand. (5) streak: the current streak traced day by day with a trail, the last day off marked. (6) terminal: a typed command, then glyphs that scramble and settle into . : + # @ by level.

Use my site's colours and fonts: an empty-day colour close to the background and four steps of my accent colour for the levels. Make it work on a phone.

What I'd tell you before you build one

  • Use GitHub's levels, not your own. They are already relative to your year, so a quiet person and a busy person both get a graph that looks full.
  • Square-root anything that becomes a height. One spike otherwise flattens a year of work.
  • Save the data to a file. The page stays fast, has no secrets in it, and still works the day GitHub's API has a bad afternoon.

The code is the lab page itself: /lab/commits. Hover the grid, replay the skyline, and steal whichever one fits your site.