A tour of the GraphQL fetch, daily JSON snapshot, and Astro SVG behind the little grid on my homepage.
GitHub’s contribution graph is a tiny productivity dashboard disguised as confetti. I wanted one on my homepage, in the site’s colors, without a widget making another request whenever somebody visited.
The setup is pretty small: a Node script asks GitHub for the calendar, an Action refreshes a JSON snapshot every day, and an Astro component turns that snapshot into SVG. The browser doesn’t ask GitHub for anything. It gets the finished grid, plus one tiny script that moves the horizontal scroller to the latest week.
Get the calendar from GitHub
The GraphQL API already returns contributions grouped by week and day. Each day comes with a date, a count, a weekday number, and an intensity level. That’s most of what the drawing code needs:
Terminal window
queryGetCalendar{
viewer{
login
calendar:contributionsCollection{
contributionCalendar{
totalContributions
weeks{
contributionDays{
date
contributionCount
contributionLevel
weekday
}
}
}
contributionYears
}
}
}
viewer means the account behind the token. contributionLevel is NONE or one of four contribution quartiles, and weekday tells me which row each day belongs in. The full fetcher uses a longer query that also asks for this month’s and this year’s totals for the little stats cards. GitHub counts several kinds of activity here, including issues, pull requests, and commits, so the calendar is not a commit counter. The ContributionsCollection fields have the full list.
The fetch script looks for a token in the environment first. When I’m running it on my machine, it can also use the GitHub CLI login:
Terminal window
import{execSync}from'node:child_process';
functionresolveToken(){
if(process.env.GITHUB_TOKEN?.trim()){
returnprocess.env.GITHUB_TOKEN.trim();
}
if(process.env.GH_TOKEN?.trim()){
returnprocess.env.GH_TOKEN.trim();
}
try{
constout=execSync('gh auth token',{
encoding:'utf-8',
stdio:['ignore','pipe','ignore'],
});
if(out?.trim())returnout.trim();
}catch{
// The GitHub CLI isn't installed or logged in.
}
returnnull;
}
For private and internal activity, GitHub requires the optional read:user scope on the token. The script uses viewer, so it also checks that the token belongs to the expected account before writing anything. It refuses to replace the saved data with an empty calendar, too. That way a wrong token or a bad response doesn’t quietly turn the homepage into a very convincing week off.
Add the older years without a request per year
The calendar query includes contributionYears. I use GraphQL aliases to ask for every year’s totals in one more request, instead of making a separate round trip for each year:
Terminal window
constyearAliases=years
.map(
(year)=>
`y${year}: contributionsCollection(from: "${year}-01-01T00:00:00Z", to: "${year}-12-31T23:59:59Z") {
Each y2025, y2024, and so on is an alias in the response. The script adds those results together for the all-time card. It then writes the calendar and totals to src/data/github-contributions.json. The snapshot contains counts, dates, weekdays, and intensity levels; the token stays in the environment and never goes into the page.
Keep the snapshot fresh
A GitHub Actions workflow runs the script at 02:00 UTC each day. I can also start it manually with workflow_dispatch, which is handy when testing and less handy when I forget what time zone I’m in. In .github/workflows/update-contributions.yml, the schedule and permission bits look like this:
Terminal window
on:
schedule:
-cron:'0 2 * * *'
workflow_dispatch:
permissions:
contents:write
The workflow passes a repository secret named GH_PAT to the script. That token needs permission to read the account’s contribution data and push the updated JSON file. After fetching and setting the bot’s Git identity, the workflow checks whether the snapshot actually changed before it makes a commit:
No new activity means no empty commit. On my laptop, the script can use the checked-in snapshot if I don’t have a token handy; in CI, a missing token is an error.
Draw the grid with Astro and SVG
The Astro component imports the JSON file directly:
Astro reads that file while building the site. There is no browser request to GitHub and no chart library to download. The component loops over the weeks and draws one SVG rectangle for each day.
I used 10-pixel squares with a 3-pixel gap. weeks.length comes from GitHub, so the width adapts to the calendar instead of assuming it will always be exactly 53 weeks:
Terminal window
constCELL_SIZE=10;
constCELL_GAP=3;
constSTEP=CELL_SIZE+CELL_GAP;
constX_OFFSET=30;// room for weekday labels
constY_OFFSET=20;// room for month labels
consttotalWeeks=weeks.length;
constsvgWidth=X_OFFSET+totalWeeks*STEP;
constsvgHeight=Y_OFFSET+7*STEP;
A day’s weekday gives its row; the week index gives its column. GitHub’s contribution level maps to one of five CSS classes, from empty to busiest:
Terminal window
constlevelClassMap:Record<string,string>={
NONE:'gh-cell-0',
FIRST_QUARTILE:'gh-cell-1',
SECOND_QUARTILE:'gh-cell-2',
THIRD_QUARTILE:'gh-cell-3',
FOURTH_QUARTILE:'gh-cell-4',
};
Then the nested loop does the repetitive part. The <title> gives each square a date and count when you hover it:
Terminal window
<svg
viewBox={`0 0 ${svgWidth}${svgHeight}`}
class="heatmap-svg"
role="img"
aria-label={`GitHub contribution heatmap for @${username}`}
consttitle=`${day.contributionCount} contribution${day.contributionCount===1?'':'s'} on ${date}`;
return(
<rect
x={x}
y={y}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
ry={2}
class={`gh-cell ${levelClass}`}
data-date={day.date}
data-count={day.contributionCount}
>
<title>{title}</title>
</rect>
);
})}
</g>
);
})
}
</svg>
I also print month names across the top and just Monday, Wednesday, and Friday down the side. Seven weekday labels made the gutter noisy at this size. The SVG sits in a horizontally scrollable container on small screens, and a tiny inline script moves that container to the newest week on load.
Give the levels their own colors
The data says how busy a day was; CSS decides what that looks like. I used a muted terracotta ramp for the light theme and a brighter version for dark mode:
Terminal window
:root{
--gh-level-0:#ede8de;
--gh-level-1:#f4dcd3;
--gh-level-2:#e39c84;
--gh-level-3:#c8502e;
--gh-level-4:#872e15;
}
:global(html.dark) {
--gh-level-0:#222428;
--gh-level-1:#3d231b;
--gh-level-2:#733725;
--gh-level-3:#c05435;
--gh-level-4:#e06b47;
}
.gh-cell-0{fill:var(--gh-level-0);}
.gh-cell-1{fill:var(--gh-level-1);}
.gh-cell-2{fill:var(--gh-level-2);}
.gh-cell-3{fill:var(--gh-level-3);}
.gh-cell-4{fill:var(--gh-level-4);}
The colors can change with the theme without fetching data again or rebuilding the grid in JavaScript.
Put it on the homepage
With the component ready, adding it to the page was just an import and a tag. I placed it after the intro and before the post lists:
That’s the whole pipeline: GitHub data, a saved JSON file, then SVG. Here’s a rendered example on this post; the homepage version reads from the refreshed snapshot.
Public commits and private client activity. None of this measures code quality.
LessMore
The complete heatmap-only implementation—including the fetch script, workflow, Astro component, and homepage insertion—is in this GitHub commit. The grid is several hundred little rectangles. The interesting part was making sure they had something current to say.