Contribution Calendar
A year of activity squares, one per day and a column per week, like a code-hosting profile. The days light up one after another from a year ago to today while the total counts up above.
How to use it
- Paste the snippet just before
</head>. - Add
data-contribution-calendarto an empty element, withdata-valueslisting a count for each day, oldest first and ending today.
Options
Add any of these attributes to the same element.
data-values | Comma-separated counts, one per day. Leave it out for made-up activity. |
data-weeks | How many weeks to show. Default 53. |
data-color | Colour of the busiest days. Default #a855f7. |
data-label | Text after the total. Default contributions. |
data-labels | false hides the month and day names. |
data-duration | Time to fill the whole calendar. Default 2600 ms. |
data-loop | Clear and fill again every few seconds. |
Squares never get smaller than 8 px: on a narrow screen it shows the most recent weeks that fit and says how far back it goes. The four shades split your active days into quarters.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Contribution Calendar: a year of activity squares, one per day, week by
// week like a code-hosting profile. The days light up one after another
// from the oldest to today while the total counts up above.
// Usage: <div data-contribution-calendar></div>
// Optional: data-values="0,3,1,…" (a count per day, oldest first, ending
// today; leave it out for made-up activity), data-weeks (how many weeks,
// default 53; fewer are shown when the squares would get too small),
// data-color (default #a855f7), data-label (text after the total, default
// "contributions"), data-labels="false" (no month and day names),
// data-duration (ms to fill the whole calendar, default 2600),
// data-loop (clear and fill again every few seconds).
var DEFAULTS = { weeks: 53, color: '#a855f7', label: 'contributions', duration: 2600, hold: 2800, minCell: 8 };
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
var DAY = 864e5;
var css =
'.ccal{font-size:11px;line-height:1.2;text-align:left;font-variant-numeric:tabular-nums}' +
'.ccal-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:8px;font-size:13px}' +
'.ccal-head b{font-weight:800}' +
'.ccal-grid{display:grid;grid-auto-flow:column;gap:var(--ccal-gap)}' +
'.ccal-m,.ccal-l{font-size:10px;line-height:1;opacity:.6;white-space:nowrap;align-self:center}' +
'.ccal-m{width:0;align-self:end;padding-bottom:3px}' +
'.ccal-l{padding-right:4px}' +
'.ccal-d{aspect-ratio:1;border-radius:22%;background:color-mix(in srgb,currentColor 8%,transparent);transition:background-color .35s}' +
'.ccal-d.ccal-x{visibility:hidden}' +
'.ccal-d.ccal-pop{animation:ccal-pop .45s cubic-bezier(.3,1.6,.5,1)}' +
'@keyframes ccal-pop{0%{transform:scale(.3)}100%{transform:scale(1)}}' +
'.ccal-legend{display:flex;align-items:center;justify-content:flex-end;gap:3px;margin-top:8px;opacity:.75}' +
'.ccal-legend i{width:10px;height:10px;border-radius:22%}' +
'.ccal-legend em{font-style:normal;margin:0 4px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
function mix(color, pct) { return 'color-mix(in srgb,' + color + ' ' + pct + '%,color-mix(in srgb,currentColor 8%,transparent))'; }
// Made-up activity: quieter weekends, busy spells, the odd day off.
function sample(days, endDay) {
var seed = 11, out = [];
function rnd() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }
for (var i = 0; i < days; i++) {
var dow = (endDay - (days - 1 - i) + 7 * 1000) % 7;
var weekend = dow === 0 || dow === 6;
var spell = Math.sin(i / 17) * 0.5 + Math.sin(i / 5.3) * 0.3 + 0.5;
var p = weekend ? 0.25 : 0.5 + spell * 0.4;
out.push(rnd() < p ? Math.round(rnd() * spell * 11) + 1 : 0);
}
return out;
}
function setup(el) {
var d = el.dataset;
var today = new Date();
today.setHours(12, 0, 0, 0);
var weeks = +d.weeks || DEFAULTS.weeks;
var color = d.color || DEFAULTS.color;
// The grid runs Sunday to Saturday, ending with this week.
var days = (weeks - 1) * 7 + today.getDay() + 1;
var counts = d.values ? d.values.split(',').map(Number) : sample(days, today.getDay());
// Line the data up so its last entry is today.
while (counts.length < days) counts.unshift(0);
counts = counts.slice(counts.length - days);
var nonzero = counts.filter(function (n) { return n > 0; }).sort(function (a, b) { return a - b; });
var q = [0.25, 0.5, 0.75].map(function (p) { return nonzero[Math.floor(p * (nonzero.length - 1))] || 0; });
var levels = counts.map(function (n) { return n <= 0 ? 0 : n <= q[0] ? 1 : n <= q[1] ? 2 : n <= q[2] ? 3 : 4; });
var start = new Date(today.getTime() - (days - 1) * DAY);
el.classList.add('ccal');
var c = {
el: el, weeks: weeks, days: days, counts: counts, levels: levels, start: start,
shades: [0, 32, 56, 80, 100].map(function (p) { return p ? mix(color, p) : ''; }),
label: d.label || DEFAULTS.label, labels: d.labels !== 'false', lit: 0, shown: -1
};
el._ccal = c;
layout(c);
return c;
}
// How many weeks fit at a readable size.
function fit(c) {
var w = c.el.clientWidth - (c.labels ? 30 : 0);
var gap = w > 500 ? 3 : 2;
return Math.max(4, Math.min(c.weeks, Math.floor((w + gap) / (DEFAULTS.minCell + gap))));
}
// Build the grid for the weeks that fit; earlier weeks are left off.
function layout(c) {
var shown = fit(c);
if (shown === c.shown) return;
c.shown = shown;
var gap = c.el.clientWidth > 500 ? 3 : 2;
var first = (c.weeks - shown) * 7; // index of the first day shown
c.first = first;
var total = 0, i;
for (i = first; i < c.days; i++) total += c.counts[i];
c.total = total;
var span = shown >= 52 ? 'in the last year' : shown >= 8 ? 'in the last ' + Math.round(shown / 4.35) + ' months' : 'in the last ' + shown + ' weeks';
// The grid fills column by column: an optional month row on top, then Sunday to Saturday.
var rows = c.labels ? 8 : 7, grid = '';
if (c.labels) grid += '<span></span>' + ['', 'Mon', '', 'Wed', '', 'Fri', ''].map(function (n) { return '<span class="ccal-l">' + n + '</span>'; }).join('');
for (var w = 0; w < shown; w++) {
if (c.labels) {
// Name the month over the week that holds its 1st (if there's room to the right).
var label = '', day = c.start.getTime() + (first + w * 7) * DAY;
for (var k = 0; k < 7; k++) {
var dd = new Date(day + k * DAY);
if (dd.getDate() === 1 && w < shown - 2) { label = MONTHS[dd.getMonth()]; break; }
}
grid += '<span class="ccal-m">' + label + '</span>';
}
for (k = 0; k < 7; k++) grid += '<span class="ccal-d' + (first + w * 7 + k >= c.days ? ' ccal-x' : '') + '"></span>';
}
var legend = '<div class="ccal-legend" aria-hidden="true"><em>Less</em>' +
c.shades.map(function (s) { return '<i style="background:' + (s || 'color-mix(in srgb,currentColor 8%,transparent)') + '"></i>'; }).join('') + '<em>More</em></div>';
c.el.style.setProperty('--ccal-gap', gap + 'px');
c.el.innerHTML =
'<div class="ccal-head"><span><b class="ccal-n">0</b> ' + esc(c.label) + ' ' + span + '</span></div>' +
'<div class="ccal-grid" aria-hidden="true" style="grid-template-rows:repeat(' + rows + ',auto);grid-template-columns:' +
(c.labels ? 'auto ' : '') + 'repeat(' + shown + ',minmax(0,1fr))">' + grid + '</div>' + legend;
c.el.setAttribute('role', 'img');
c.el.setAttribute('aria-label', total.toLocaleString() + ' ' + c.label + ' ' + span + '.');
c.cells = Array.prototype.slice.call(c.el.querySelectorAll('.ccal-d'));
c.num = c.el.querySelector('.ccal-n');
c.lit = 0;
if (c.done) light(c, c.days - first, false);
}
// Light every day up to n (counted from the first day shown).
function light(c, n, pop) {
n = Math.min(n, c.days - c.first);
var sum = +c.num.getAttribute('data-sum') || 0;
if (n < c.lit) { sum = 0; c.lit = 0; }
for (var i = c.lit; i < n; i++) {
var cell = c.cells[i], lv = c.levels[c.first + i];
sum += c.counts[c.first + i];
if (!lv) continue;
cell.style.backgroundColor = c.shades[lv];
if (pop) cell.classList.add('ccal-pop');
}
c.lit = n;
c.num.setAttribute('data-sum', sum);
c.num.textContent = sum.toLocaleString();
}
function clear(c) {
c.cells.forEach(function (cell) { cell.style.backgroundColor = ''; cell.classList.remove('ccal-pop'); });
c.lit = 0;
c.num.setAttribute('data-sum', 0);
c.num.textContent = '0';
}
function play(c) {
var d = c.el.dataset;
var duration = +d.duration || DEFAULTS.duration;
var p = { n: 0 };
c.done = false;
clear(c);
anime.animate(p, {
n: c.days - c.first, duration: duration, ease: 'inOut(1.4)',
onUpdate: function () { light(c, Math.round(p.n), true); },
onComplete: function () {
light(c, c.days, true);
c.done = true;
if (d.loop === undefined || d.loop === 'false') return;
setTimeout(function () {
c.done = false;
clear(c);
setTimeout(function () { play(c); }, 600);
}, DEFAULTS.hold);
}
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target._ccal);
});
}, { threshold: 0.3 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) { if (e.target._ccal) layout(e.target._ccal); });
});
document.querySelectorAll('[data-contribution-calendar]').forEach(function (el) {
if (el._ccal) return;
var c = setup(el);
ro.observe(el);
if (reduced()) { c.done = true; light(c, c.days, false); return; }
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-contribution-calendar
data-values="0,2,5,1,0,0,3,…"></div>