GitHub-Style Contribution Heatmap — Free HTML CSS JS Calendar Snippet
GitHub-Style Contribution Heatmap · Dev · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="chm-card">
<div class="chm-head">
<div>
<h3>Contribution activity</h3>
<p id="chmTotal">— contributions in the last year</p>
</div>
<div class="chm-legend">
<span>Less</span>
<span class="chm-swatch" data-level="0"></span>
<span class="chm-swatch" data-level="1"></span>
<span class="chm-swatch" data-level="2"></span>
<span class="chm-swatch" data-level="3"></span>
<span class="chm-swatch" data-level="4"></span>
<span>More</span>
</div>
</div>
<div class="chm-scroll">
<div class="chm-months" id="chmMonths"></div>
<div class="chm-body">
<div class="chm-days">
<span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span><span></span>
</div>
<div class="chm-grid" id="chmGrid"></div>
</div>
</div>
<div class="chm-tooltip" id="chmTooltip" hidden></div>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.chm-card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:20px;width:100%;max-width:760px;position:relative}
.chm-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.chm-head h3{font-size:14px;font-weight:700;color:#e6edf3}
.chm-head p{font-size:12px;color:#7d8590;margin-top:2px}
.chm-legend{display:flex;align-items:center;gap:4px;font-size:11px;color:#7d8590}
.chm-swatch{width:11px;height:11px;border-radius:2px;background:#161b22;border:1px solid rgba(240,246,252,.08)}
.chm-swatch[data-level="0"]{background:#161b22}
.chm-swatch[data-level="1"]{background:#0e4429}
.chm-swatch[data-level="2"]{background:#006d32}
.chm-swatch[data-level="3"]{background:#26a641}
.chm-swatch[data-level="4"]{background:#39d353}
.chm-scroll{overflow-x:auto;padding-bottom:4px}
.chm-months{display:grid;grid-auto-flow:column;grid-auto-columns:11px;gap:3px;margin-left:28px;margin-bottom:4px;font-size:10px;color:#7d8590;white-space:nowrap}
.chm-body{display:flex;gap:6px}
.chm-days{display:grid;grid-template-rows:repeat(7,11px);gap:3px;font-size:9px;color:#7d8590;width:22px;text-align:right;line-height:11px}
.chm-grid{display:grid;grid-template-rows:repeat(7,11px);grid-auto-flow:column;gap:3px}
.chm-cell{width:11px;height:11px;border-radius:2px;background:#161b22;border:1px solid rgba(240,246,252,.06);cursor:pointer;transition:outline .1s}
.chm-cell[data-level="1"]{background:#0e4429}
.chm-cell[data-level="2"]{background:#006d32}
.chm-cell[data-level="3"]{background:#26a641}
.chm-cell[data-level="4"]{background:#39d353}
.chm-cell:hover{outline:1px solid #e6edf3}
.chm-tooltip{position:fixed;background:#1c2128;border:1px solid #30363d;color:#e6edf3;font-size:11.5px;padding:6px 10px;border-radius:6px;pointer-events:none;white-space:nowrap;z-index:10;box-shadow:0 8px 20px rgba(0,0,0,.4)}
.chm-tooltip[hidden]{display:none}(function(){
var DAY = 24 * 60 * 60 * 1000;
var WEEKS = 53;
var today = new Date();
today.setHours(0,0,0,0);
var start = new Date(today.getTime() - (WEEKS * 7 - 1) * DAY);
// align start to a Sunday so weeks stack into clean 7-row columns
start = new Date(start.getTime() - start.getDay() * DAY);
var grid = document.getElementById('chmGrid');
var monthsRow = document.getElementById('chmMonths');
var tooltip = document.getElementById('chmTooltip');
var totalEl = document.getElementById('chmTotal');
var monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
var days = [];
var cursor = new Date(start);
while (cursor <= today) {
// pseudo-random but deterministic-looking activity: weekday bias + occasional bursts
var isWeekend = cursor.getDay() === 0 || cursor.getDay() === 6;
var r = Math.random();
var count = 0;
if (cursor > today) count = 0;
else if (r < (isWeekend ? 0.55 : 0.3)) count = 0;
else if (r < 0.6) count = Math.floor(Math.random() * 3) + 1;
else if (r < 0.85) count = Math.floor(Math.random() * 6) + 3;
else count = Math.floor(Math.random() * 12) + 8;
days.push({ date: new Date(cursor), count: cursor > today ? -1 : count });
cursor = new Date(cursor.getTime() + DAY);
}
function levelFor(count) {
if (count <= 0) return 0;
if (count <= 2) return 1;
if (count <= 5) return 2;
if (count <= 10) return 3;
return 4;
}
var total = 0;
var lastMonthLabel = -1;
var weekIndex = 0;
days.forEach(function (d, i) {
var dow = i % 7;
if (dow === 0 && i !== 0) weekIndex++;
var cell = document.createElement('div');
cell.className = 'chm-cell';
if (d.count >= 0) {
cell.setAttribute('data-level', String(levelFor(d.count)));
total += d.count;
} else {
cell.style.visibility = 'hidden';
}
var label = d.date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
cell.addEventListener('mouseenter', function (e) {
if (d.count < 0) return;
tooltip.hidden = false;
tooltip.textContent = (d.count === 0 ? 'No contributions' : d.count + ' contribution' + (d.count === 1 ? '' : 's')) + ' on ' + label;
positionTooltip(e);
});
cell.addEventListener('mousemove', positionTooltip);
cell.addEventListener('mouseleave', function () { tooltip.hidden = true; });
grid.appendChild(cell);
if (dow === 0 && d.date.getMonth() !== lastMonthLabel) {
lastMonthLabel = d.date.getMonth();
var m = document.createElement('span');
m.style.gridColumn = String(weekIndex + 1);
m.textContent = monthNames[d.date.getMonth()];
monthsRow.appendChild(m);
}
});
function positionTooltip(e) {
tooltip.style.left = (e.clientX + 12) + 'px';
tooltip.style.top = (e.clientY - 30) + 'px';
}
totalEl.textContent = total.toLocaleString() + ' contributions in the last year';
})();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Contribution Heatmap — CSS Grid Calendar with Level-Based Color Coding

A contribution heatmap turns a year of daily activity counts into a single scannable grid, the pattern popularized by GitHub's profile page and now common on habit trackers, analytics dashboards, and streak-tracking apps. This snippet builds the full grid, color scale, and hover tooltip from scratch using CSS Grid and vanilla JavaScript — no charting library required.
Building the date grid
The script computes a 53-week window ending today, then rewinds start to the previous Sunday with start = new Date(start.getTime() - start.getDay() * DAY) so every week lands cleanly into 7 rows. It then walks forward one day at a time, pushing { date, count } objects into a flat days array — day 0 is the top-left cell, day 6 wraps to the next column, matching how grid-auto-flow: column lays cells out.
CSS Grid does the calendar layout
.chm-grid uses grid-template-rows: repeat(7, 11px) with grid-auto-flow: column, so pushing 371 cells in date order automatically arranges them into weeks-as-columns without any manual row/column math in JavaScript — the grid algorithm handles wrapping every 7 cells into a new column for you.
A five-level, not continuous, color scale
Rather than mapping raw counts to color via hsl() interpolation, levelFor() buckets counts into five discrete tiers (0, 1–2, 3–5, 6–10, 11+) matching GitHub's actual convention. Discrete levels read faster at a glance than a continuous gradient because the eye only has to distinguish five states, not infinite shades — each level maps to a fixed data-level attribute and a corresponding CSS background color.
Month labels aligned to grid columns
As the loop walks days, it detects a Sunday that starts a new month (dow === 0 && d.date.getMonth() !== lastMonthLabel) and appends a label positioned with style.gridColumn = weekIndex + 1, keeping the month row's labels aligned to the exact week column they belong to even though months don't divide evenly into 53 weeks.
A tooltip that follows the cursor
Each cell gets mouseenter/mousemove/mouseleave listeners that show a fixed-position tooltip offset from e.clientX/e.clientY, formatted with toLocaleDateString() for a locale-correct date string — cheaper than a full tooltip library for a grid with hundreds of hoverable cells.
Customizing it
Swap the random data generator for a real fetch() call to your activity API, change WEEKS to show a shorter range, or adjust the levelFor() thresholds to match your own data's distribution.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA 53-week calendar grid renders with randomly generated activity levels and a color legend.
- 2Hover a cellA tooltip shows the exact contribution count and date for that day.
- 3Scroll horizontallyOn narrow viewports the grid scrolls inside its own container so the page never scrolls sideways.
- 4Replace the data sourceSwap the random count generator in the JS for a fetch() call returning real daily counts.
- 5Adjust the color levelsEdit levelFor() thresholds and the .chm-cell[data-level] CSS colors to match your brand palette.
- 6Change the time rangeEdit the WEEKS constant to show a shorter or longer history than 53 weeks.
Real-world uses
Common Use Cases
grid-auto-flow: column arranging 371 cells into weekly columns.style.gridColumn.Got questions?
Frequently Asked Questions
CSS Grid's grid-auto-flow: column wraps every 7 cells into a new column. If the first day isn't a Sunday, the first week would be a partial column and every subsequent week would be visually misaligned. Rewinding start to the prior Sunday guarantees every column is a clean 7-day week.
Replace the days.push({ date, count }) loop with data fetched from your API — for example fetch('/api/activity').then(r => r.json()), where the response is an array of { date, count } objects covering the same date range. Keep the date objects normalized to midnight so day-of-week math stays correct.
levelFor(count) buckets the raw count into one of five tiers (0, 1, 2, 3, 4) using fixed thresholds. Each cell gets a data-level attribute, and the CSS selects a background color per level with .chm-cell[data-level="N"]. Edit the thresholds in levelFor() to fit your own data's typical range.
Yes — the cell.addEventListener block already wires up mouseenter/mousemove/mouseleave for the tooltip. Add a click listener in the same forEach loop that reads d.date and d.count and opens a modal, navigates to a detail page, or expands an inline panel.
A five-level scale is easier to read at a glance across hundreds of cells because the eye only has to distinguish five known states rather than judge a continuous gradient's exact shade. It also matches the convention most developers already recognize from GitHub, so no legend explanation is needed.