← Charts

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

  1. Paste the snippet just before </head>.
  2. Add data-contribution-calendar to an empty element, with data-values listing a count for each day, oldest first and ending today.

Options

Add any of these attributes to the same element.

data-valuesComma-separated counts, one per day. Leave it out for made-up activity.
data-weeksHow many weeks to show. Default 53.
data-colorColour of the busiest days. Default #a855f7.
data-labelText after the total. Default contributions.
data-labelsfalse hides the month and day names.
data-durationTime to fill the whole calendar. Default 2600 ms.
data-loopClear 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.

1 · Snippet
<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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>
2 · Your HTML
<div data-contribution-calendar
     data-values="0,2,5,1,0,0,3,…"></div>