GitHub profile READMEs are a strange place to draw anything. Your image is served through GitHub's image proxy and embedded with an tag. That means no JavaScript, no web fonts, no external stylesheets, and no idea whether the viewer uses the light or the dark theme. On top of that, I wanted the image regenerated every day by a GitHub Action without changing a single byte when the data hadn't changed.
Within those limits, I wanted a 3D contribution graph: every day of the last year as a little isometric bar, its height and colour growing with the work done, rising into place when the page loads.
This post walks through how the 3D card in Profilescape works. Profilescape is an open-source GitHub Action that renders profile cards from your own token. The whole card is plain SVG paths and one block.
The grid: 53 Sunday-aligned weeks
GitHub's contribution calendar is a grid of weeks (columns) by weekdays (rows), with weeks starting on Sunday. The first step turns a list of { date, count } days into cells with a week and a day index, ending today:
/** The last `weeks` calendar weeks ending today (UTC), Sunday-aligned. */exportfunctionyearWindow(calendar:ContributionDay[],now:Date,weeks=53):CalendarCell[]{consttoday=parseDate(isoDate(now));conststart=today.getTime()-(today.getUTCDay()+(weeks-1)*7)*DAY;constcounts=countsByDate(calendar);constcells:CalendarCell[]=[];for (lett=start,i=0;t<=today.getTime();t+=DAY,i++){constdate=isoDate(newDate(t));cells.push({week:Math.floor(i/7),day:i%7,date,count:counts.get(date)??0});}returncells;}
Notice now is a parameter. Nothing in the renderer calls Date.now() or Math.random(). That's what makes the output deterministic, which matters a lot later.
Isometric projection with two vectors
Real isometric projection uses 30° angles. I didn't need real isometry, just a pleasant oblique view where weeks run off to the right and weekdays run down and to the left. So the projection is two basis vectors in screen space: one step along a week moves (wx, wy), and one step along a weekday moves (dx, dy).
// Weeks run right and slightly down, weekdays left and down. Drawing week by// week (and day by day inside a week) is a correct painter's order for this// projection: any two bars whose silhouettes overlap are ordered by both axes.consts=Math.min(1.4,964/(weeks*17+63));constwx=17*s;constwy=5.5*s;constdx=-9*s;constdy=8*s;constpx=(w:number,d:number)=>w*wx+d*dx;constpy=(w:number,d:number)=>w*wy+d*dy;
s scales everything so the slab fits a 1200px-wide card whether you show 26 weeks or 53. Height is just a vertical offset: a point at grid position (w, d) and height h lands at (px(w, d), py(w, d) - h).
Each day is three paths
From this viewpoint you can see three faces of a bar: the front (the long side that runs along the week axis, facing the front edge of the slab), the right side (facing the next week), and the top. Each one is a parallelogram, so each one is a short path with relative commands:
constk=1-2*GAP;// bars are inset by GAP so a hairline of floor shows between themconsttopLoop=`l${rel(wx*k,wy*k,dx*k,dy*k,-wx*k,-wy*k)}z`;constfront=`M${pt(w+GAP,d+1-GAP)}l${rel(wx*k,wy*k)}v-${hs}l${rel(-wx*k,-wy*k)}z`;constright=`M${pt(w+1-GAP,d+1-GAP)}l${rel(-dx*k,-dy*k)}v-${hs}l${rel(dx*k,dy*k)}z`;consttop=`M${pt(w+GAP,d+GAP,h)}${topLoop}`;
The top face is the same shape for every bar, only moved, so topLoop is built once and reused, and only the M (move to) changes. The vertical edges are a single v-. Relative commands keep the numbers short, which matters because a busy year can have over a thousand of these paths (three per active day). Days with no contributions don't get bars at all: they become flat floor tiles merged into a single .
Painter's order without sorting
SVG has no depth buffer. Whatever you draw last ends up on top, so 3D in SVG usually means sorting shapes back to front. Here no sort is needed, because the projection was chosen so the loop order is already correct.
Look at the horizontal footprint of one day: its left edge is at px(w, d + 1) and its right edge at px(w + 1, d). That's wx + |dx| wide, 17 + 9 = 26 units. Now take the diagonal neighbour one week later and one weekday earlier, (w + 1, d - 1). Its left edge is at px(w + 1, d), exactly where the first bar's right edge is. Diagonal neighbours touch but never overlap, however tall they are, because height only moves points vertically.
So two bars can only overlap on screen when one of them is ahead on both axes (a later week and a later weekday, or the same in one of them). That bar is nearer the viewer. Drawing week by week, and day by day within a week, always draws the nearer one second. A plain nested loop does the job.
The loop also gives a natural unit for animation: every week becomes one .
Height: square roots, not straight lines
Contribution data has a long tail. One release day with 104 contributions next to a typical day of 3 would turn a linear scale into one skyscraper and a flat field. The default is a square-root scale with a small minimum, so a single contribution is still visible:
linear and log are options for people who prefer them. The legend always says which scale is in use ("height ∝ √contributions"), because a chart that hides its scale isn't being honest with the reader.
Colour: quartiles, like GitHub
Height shows magnitude, and colour shows rank. Like GitHub's own calendar, each active day gets one of four levels based on the quartiles of your non-zero days:
/** GitHub-style quartile levels computed from the non-zero counts. */exportfunctionlevelScale(counts:number[]):(count:number)=>Level{constnz=counts.filter((c)=>c>0).sort((a,b)=>a-b);if (!nz.length)return ()=>0;constq=[0.25,0.5,0.75].map((p)=>nz[Math.min(nz.length-1,Math.floor(nz.length*p))]??0);return (c)=>{if (c<=0)return0;if (c<=(q[0]??0))return1;if (c<=(q[1]??0))return2;if (c<=(q[2]??0))return3;return4;};}
Quartiles adapt to the person. Someone with 3 contributions a day and someone with 30 both get the full colour range.
The five colours come from the theme, not from a hard-coded green. Every Profilescape theme defines semantic tokens (empty, accentA, accentB, ...), and one function builds the ramp. The animated grid, the stats chart and the hero banner use the same ramp, so colour means the same thing everywhere:
exportfunctioncontribRamp(p:Palette,mode:Mode):[string,string,string,string,string]{constdark=mode==='dark';constpeak=dark?mix(p.accentB,'#FFFFFF',0.35):shade(p.accentB,0.15);constramp:[string,string,string,string,string]=[p.empty,mix(p.empty,p.accentA,0.45),p.accentA,mix(p.accentA,p.accentB,0.6),peak,];// ...then each level is nudged toward white (dark mode) or black (light mode)// until it is clearly lighter or darker than the one before.returnramp;}
The 3D effect itself comes from shading each level. The front face is darkened a little and the right face more, each as a vertical gradient so bars look lit from above. The top gets a thin, lighter rim. Dark mode uses stronger shading than light mode, because on a dark panel subtle differences disappear.
Without that last step, a theme with a light accent colour could make your busiest days look quieter than average ones, so the ramp enforces a minimum lightness step between levels.
Layout that doesn't move when the data changes
The card also has a headline, four insights (best day, busiest month, favourite weekday, active days) and a pin on the tallest bar. The slab rises from front-right to back-left, which leaves an empty triangle in the top-right corner where the insights fit.
But bars change height every day. If the layout depended on today's tallest bar, the slab would shift up and down from one day to the next. So the vertical origin is computed against the tallest possible bar instead:
// Lowest origin that keeps the tallest *possible* bar clear of every reserved// region, so the layout never depends on the day-to-day data and never collides.letoy=PAD+roomH+6;for (constrofreserved){for (letw=0;w<weeks;w++){for (letd=0;d<7;d++){constx0=ox+px(w+GAP,d+1-GAP);constx1=ox+px(w+1-GAP,d+GAP);if (x1<r.x0-12||x0>r.x1+12)continue;oy=Math.max(oy,r.y1+CLEARANCE-(py(w+GAP,d+GAP)-roomH));}}}
The best-day pin works the other way: it tries a short list of candidate positions above and beside the summit and takes the first one that collides with nothing. If none fits, the pin is dropped. It never covers the headline.
Animation that degrades to the finished card
Scripts never run in README images, but CSS animations inside an SVG do. Each week's rises with a staggered delay:
First, the keyframes only define from. The end state is whatever the element already is: fully opaque, in place.
Second, animation-fill-mode: backwards (not forwards, not both). backwards applies the from state during the delay, so bars don't flash before their turn. Once the animation ends, the element goes back to its own styles, which are the finished card. Anything that ignores the animation sees that finished card too: viewers with prefers-reduced-motion, PNG renderers, and link previews. If I'd written bars as opacity: 0 and animated them in with forwards, all of those would show an empty slab.
Every card shares one frame function that adds the reduced-motion rule:
Every card is rendered twice, once per theme mode, and the README markup uses . GitHub follows the viewer's theme setting:
media="(prefers-color-scheme: dark)"srcset="https://raw.githubusercontent.com///profilescape-output/3d-dark.svg"/>src="https://raw.githubusercontent.com///profilescape-output/3d-light.svg"alt="3D contribution landscape: 2,083 contributions in the last year"width="100%"/>
The alt text states the total. The SVG also carries a and a with a fuller written summary (best day, busiest month, favourite weekday, active days) for anyone who opens the file directly.
Why determinism matters: zero-churn publishing
Because the same data produces byte-identical SVG, the Action can tell when nothing has changed without uploading anything. It computes git's own blob and tree hashes locally and compares them with the output branch:
/** SHA-1 of a git blob object, exactly as git computes it. */exportfunctiongitBlobSha(content:string|Uint8Array):string{constbytes=toBytes(content);returncreateHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');}
If the tree hash matches the branch's current tree, the run ends with "nothing to publish". Otherwise it writes one orphan commit (no parents), so the output branch never accumulates a history of images.
The same property makes the tests exact, and it lets the website playground run the identical renderer in the browser: the card code imports nothing from Node, and the clock is passed in.
Without a token:npx github:chethandvg/profilescape --demo writes the SVGs to ./profilescape
On your profile: add and to your profile README and a workflow that uses chethandvg/profilescape@v1, or start from the template repository.
The whole renderer is about 500 lines in src/cards/landscape.ts, MIT licensed, with no runtime dependencies. If you know a better way to handle any of this, especially text measurement without fonts, I'd like to hear it in the comments or in Discussions.
Việc dùng SVG thuần để vẽ isometric landscape mà không cần JavaScript là một hướng tiếp cận rất thông minh để tối ưu performance và tính accessibility. Cách tiếp cận này giúp tránh được tình trạng layout shift khi trang web tải xong, nhất là với các đồ họa phức tạp. Mình từng gặp rắc rối với việc quản lý quá nhiều path khi scale dữ liệu lớn, nên việc tận dụng CSS để xử lý animation thay vì dùng JS engine là một bài học hay về việc tối ưu hóa DOM. Nếu bạn định mở rộng sang việc render dữ liệu động từ API, hãy cẩn thận với việc số lượng element SVG tăng quá nhanh sẽ làm nặng trình duyệt PS: the tool I meant is on labagent .tech
Comment hidden by post author - thread only accessible via permalink
Yeah, I lean on text-anchor a lot already (all the centered/right-aligned labels), plus tspan dx for the "number + label" bits. Where it breaks down is when something else has to be sized to the text: pill backgrounds, wrapping descriptions, cutting off long repo names with "…". There I still need a width guess.
textLength for those fixed-width cases is a good shout though, I'm not using it anywhere. Pinning badge labels to the estimated width with lengthAdjust="spacing" would stop them looking slightly off when the real font is wider or narrower than my guess. Going to try that.
Some comments have been hidden by the post's author - find out more
For further actions, you may consider blocking this person and/or reporting abuse
We're a place where coders share, stay up-to-date and grow their careers.
Top comments (3)
Việc dùng SVG thuần để vẽ isometric landscape mà không cần JavaScript là một hướng tiếp cận rất thông minh để tối ưu performance và tính accessibility. Cách tiếp cận này giúp tránh được tình trạng layout shift khi trang web tải xong, nhất là với các đồ họa phức tạp. Mình từng gặp rắc rối với việc quản lý quá nhiều path khi scale dữ liệu lớn, nên việc tận dụng CSS để xử lý animation thay vì dùng JS engine là một bài học hay về việc tối ưu hóa DOM. Nếu bạn định mở rộng sang việc render dữ liệu động từ API, hãy cẩn thận với việc số lượng element SVG tăng quá nhanh sẽ làm nặng trình duyệt PS: the tool I meant is on labagent .tech
Could text-anchor eliminate most of the need to measure text, reserving textLength/lengthAdjust for labels with hard width constraints?
Yeah, I lean on text-anchor a lot already (all the centered/right-aligned labels), plus tspan dx for the "number + label" bits. Where it breaks down is when something else has to be sized to the text: pill backgrounds, wrapping descriptions, cutting off long repo names with "…". There I still need a width guess.
textLength for those fixed-width cases is a good shout though, I'm not using it anywhere. Pinning badge labels to the estimated width with lengthAdjust="spacing" would stop them looking slightly off when the real font is wider or narrower than my guess. Going to try that.
Some comments have been hidden by the post's author - find out more