RSSAmplifier

BurgeonLab: Full-text · Oct 12, 2025

How to Add a GitHub Style Hugo Calendar Heatmap Widget

0
Sign in to vote or save

Naty S · BurgeonLab

Turn your Hugo blog posts into a GitHub-like contribution calendar with Apache ECharts. I'll show you how to extract blog post metadata to build a heatmap calendar using the Open Source JS Library. Including how to integrate it with Hugo partials and CSS, full code snippets, and tips on how to customize styling, scaling, and tooltip behavior.

Update

I realise this Hugo calendar heatmap could be a good opportunity for me to create my first public ‘project’ repository, making it easier for anyone to get the code or variations of it, especially if I make changes to the current version. I will update this paragraph when I have the Git repo up and running!

Introduction

In this post, I will go through how I added a GitHub like contribution calendar to my Hugo blog (it should work on other CMS).

Screenshot of the contribution github calendar heatmap for visualizing blogs posts over a year.

My final blog calendar widget looks like this

Screenshot of the GitHub contribution calendar

The GitHub Calendar I tried to replicate

The JavaScript-based calendar displays the days a post is published and uses a heatmap (colour variance) to indicate the word count. It’s a nice visualization of the post frequency over a year. I originally wanted to use a lightweight JS library called Heat.js, but I couldn’t get that working. After some researching, I found a Chinese blogger, @mtfront@douchi.space, creating something similar with EChart.1 Inspired by them, I have decided to also use the open source Apache EChart JS library to make a calendar heatmap visualization widget.

DISCLAIMER: I’m a self-taught, hobbyist Hugo user! I might be using the wrong terminology or doing things inefficiently (hopefully not erroneous or unsafe). Please double-check before using my code.

Process Overview

EChart supports many types of cool charts and a very thorough Getting Started Guide which I highly recommend reading. To replicate the look of the GitHub contribution calendar, I have chosen the EChart horizontal calendar with heatmap functionality. Everything is customizable to your liking, so feel free to adjust my code to fit your design preferences!

Here is a Mermaid diagram to show the main rundown:

---
title: How Mermaid Diagrams Work
---
%%{init: {'flowchart': {'diagramPadding': 10, 'nodeSpacing': 30, 'rankSpacing': 50, 'curve': 'basis'}}}%%
flowchart TD
    A["Load EChart JS library (deferred) on pages with Mermaid diagrams"] -->|Generate a selective min.js & serve locally| B[Generate client-side dataMap with Hugo post data]
    B --> |Embedded as a JavaScript Map object named dataMap| C["Filtering the map by year and counting posts/yr"]
    C --> |Reads config, builds the chart dynamically, showing current year as default| D["Adds interactive tooltips and clickable links to posts 🗓️"]
    %% Other Styles
    linkStyle default stroke:#FFB800,stroke-width:2px

A .JSON with the necessary data is generated on Hugo build, filtering for post type = post (i.e. not pages):

  • Published date
  • Word count
  • Permalink
  • Title

Screenshot of the dataMap that is generated from eChart

Example of how the JSON looks with the raw data

Step 1: Create Hugo Partials

heatmap.html

1<div class="heatmap-wrapper">
2    <div class="heatmap-year-buttons"> <!-- The year-buttons can be removed if you only wish to show a single year -->
3        <button onclick="changeYear('2023')">2023</button>
4        <button onclick="changeYear('2024')">2024</button>
5        <button onclick="changeYear('2025')">2025</button>
6    </div>
7    <div id="heatmap" class="heatmap"></div>
8</div>

Note on Partials

heatmap.html is to create a container (id="heatmap" class="heatmap") for the chart to be rendered into. I have set it to have the year buttons above the chart, but you can choose another way to display the years.

Use this partial on HTML pages where you want the chart to appear by using Hugo’s partial call:

1{{ partial "heatmap.html" . }}

For example, I used it on my Blog Archive page template, layouts/_default/list.html, like this:

1<h2>Blog Calendar Heatmap</h2>
2 {{ partial "heatmap.html" . }}
3 {{ partial "heatmap-data.html" . }}

heatmap-data.html

The second partial is a bit more complex, and is where most of the customization resides (other than the CSS which I will go over later). I’ll try my best to explain what it does, after the code block:

  1{{ $pages := sort .Site.RegularPages "Date" }}
  2{{ $latestDate := (index $pages (sub (len $pages) 1)).Date.Format "2006-01-02" }}
  3{{ $earliestDate := "2023-09-01" }}
  4{{ if and (eq .Kind "section") (eq .Section "posts") }}
  5
  6<script src="{{ "/js/echarts.min.js" | relURL }}" defer></script>
  7
  8{{ $heatmapCSS := resources.Get "css/heatmap.css" | minify | fingerprint }}
  9<link rel="stylesheet" href="{{ $heatmapCSS.Permalink }}" integrity="{{ $heatmapCSS.Data.Integrity }}">
 10
 11<script>
 12document.addEventListener("DOMContentLoaded", function() {
 13  var dataMap = new Map();
 14  {{ range .Site.RegularPages }} // Works on regular content pages, i.e. not list pages, taxonomy, terms.
 15    {{ if eq .Type "post" }}  // IMPORTANT: Only includes pages where Type equals "post". Your Type might be "posts" (pleural) or another term like "blog" or "blogs". If not set explicitly in the front matter of content, it will be the content's directory (folder) name.
 16      dataMap.set("{{ .Date.Format "2006-01-02" }}", {
 17        wordCount: {{ .WordCount }},
 18        link: "{{ .RelPermalink }}",
 19        title: "{{ .Title | htmlEscape }}"
 20      });
 21    {{ end }}
 22  {{ end }}
 23
 24  function filterDataByYear(year) {
 25    var filtered = [];
 26    for (const [date, value] of dataMap.entries()) {
 27      if (date.startsWith(year)) {
 28        filtered.push([date, value.wordCount]);
 29      }
 30    }
 31    return filtered;
 32  }
 33
 34  // Count the total number of posts per year
 35  function getDotCount(year) {
 36    let count = 0;
 37    for (const [date] of dataMap.entries()) {
 38        if (date.startsWith(year)) {
 39            count++;
 40        }
 41    }
 42    return count;
 43    }
 44
 45  var chartDom = document.getElementById('heatmap');
 46 var myChart = echarts.init(chartDom, null, { renderer: 'canvas' }); // Renderer can = canvas or svg. Try both to see which works better for you
 47
 48  // Resize event listener
 49    let resizeTimeout;
 50    window.addEventListener('resize', () => {
 51        clearTimeout(resizeTimeout);
 52        resizeTimeout = setTimeout(() => {
 53            myChart.resize();
 54        }, 100); // Wait 100ms after last resize event
 55    });
 56
 57  function createOption(year) {
 58    const dotCount = getDotCount(year);
 59    return {
 60    textStyle: { // Global text styles
 61       fontFamily: 'monospace'
 62    },
 63    title: [
 64        {
 65        top: 8,
 66        show: true,
 67        text: dotCount + ' posts published in ' + year,
 68        left: 'left',
 69        padding: [5, 20],
 70        textStyle: {
 71            color: '#DBDBDB',
 72            fontSize: 16,
 73            fontWeight: 600
 74        }
 75    },
 76    {
 77        text: 'ⓘ Shows days with posts, color intensity\ncorrelates to word count. Scroll → on mobile.', // Enter your own message or remove this extra description section
 78        left: 'left',
 79        bottom: 'bottom',
 80        padding: [13, 22],
 81        textStyle: {
 82            color: '#A1A1A1',
 83            fontStyle: 'italic',
 84            fontSize: 10,
 85            fontWeight: 200
 86        }
 87    }
 88],
 89    tooltip: {
 90      formatter: function (p) {
 91        const post = dataMap.get(p.data[0]);
 92        const formattedDate = echarts.format.formatTime('MM/dd', p.data[0]);
 93        return formattedDate + ' | ' + post.wordCount + ' words' + '<br/> ' + post.title;
 94      },
 95        show: true,
 96        confine: true,
 97        appendToBody: true,
 98        trigger: 'item',
 99        triggerOn: 'mousemove|click',
100        textStyle: {
101            color: "#DBDBDB",
102            fontWeight: 400,
103            fontSize: 13
104        },
105        backgroundColor: "#303030",
106        borderWidth: '1',
107        padding: [3, 6]
108    },
109    visualMap: {
110        min: 0,
111        max: 4000, // Adjust max word count to get the maximum gradient color variation, and according to your usual post length
112        splitNumber: 4, // Each colour group is 1000 words apart (4000/4)
113        type: 'piecewise',
114        orient: 'horizontal',
115        left: 'right',
116        padding: [14, 20],
117        top: 'bottom',
118        inRange: {
119          color: ['#FFB800', '#E64814'] // Light to dark gradient
120        },
121        text: ['Long', 'Short'],
122        textStyle: {
123            color: '#A1A1A1',
124            fontStyle: 'italic',
125        },
126        fontWeight: 400,
127        showLabel: false,
128         pieces: [
129            { min: 0, max: 999, label: '0-1k' },
130            { min: 1000, max: 1999, label: '1k-2k' },
131            { min: 2000, max: 2999, label: '2k-3k' },
132            { min: 3000, max: 99999, label: '3k+' },
133        ],
134        itemGap: 8,
135    },
136    calendar: {
137        top: 70,
138        left: 40,
139        bottom: 45,
140        right: 10,
141        cellSize: 'auto',
142        orient: 'horizontal',
143        range: year,
144        itemStyle: {
145          color: '#202020', // Base grid color
146          borderWidth: 2,
147          borderType: 'solid',
148          borderColor: '#1A1A1A', // Grid lines
149        },
150        yearLabel: {
151            show: false // Included year in title
152        },
153        splitLine: {  // Month-splitting lines
154          show: true, // Set to false for a more GitHub look
155          lineStyle: {
156            color: "#A1A1A1",
157            width: 0.5,
158            type: 'dotted',
159            opacity: 0.8
160            }
161        },
162        monthLabel: {
163            show: true,
164            formatter: '{nameMap}',
165            fontSize: 12,
166            color: '#A1A1A1',
167            fontWeight: 400,
168            silent: true
169        },
170        dayLabel: {
171          show: true,
172          firstDay: 1,  // Monday start, or 0 for Sunday
173          color: '#A1A1A1',
174          fontSize: 10,
175          fontWeight: 200,
176          silent: true
177        }
178    },
179    series: {
180        type: 'heatmap',
181        coordinateSystem: 'calendar',
182        data: filterDataByYear(year),
183        label: {
184            show: false,
185        }
186    }
187    };
188  }
189    window.changeYear = function(year) {
190        let option = createOption(year);
191        myChart.setOption(option, true);
192    }
193
194    window.addEventListener('resize', () => myChart.resize()); // Resize listener
195
196    myChart.on('click', function(params) {
197      if (params.componentType === 'series') {
198        const post = dataMap.get(params.data[0]);
199        if (post) {
200          const link = window.location.origin + post.link;
201          window.open(link, '_blank').focus();
202        }
203      }
204    });
205
206  // Initialize with the current year with Hugo's now.Format or manually `changeYear('2020')`
207  changeYear('{{ now.Format "2006" }}');
208  });
209
210// // Uncomment line below to access the JSON dataMap in browser console with`JSON.stringify([window.__dataMapSnapshot])`. Only works when in localhost mode or when the word debug is in the URL like `?debug`
211//   if (location.search.includes('debug') || location.hostname === 'localhost') {
212//   window.__dataMapSnapshot = Object.freeze(Object.fromEntries(dataMap));
213// }
214</script>
215{{ end }}
  • The first 4 lines are Hugo-specific template jargon. It ensures the script only generates data points from posts not pages and setting a hard-coded “beginning date”. Line 4 ensures the whole partial only runs on the blog archive page.
  • Line 6 loads the ECharts JS library. Read step 2 for more info.
  • Line 8-9 adds the related CSS file for the chart.
  • The main script steps are:
    • A dataMap using Hugo range loops is run on Hugo build, storing data for each post in JSON format (I never post more than once a day; I don’t think my current code will accommodate multiple posts per day).
    • The following two helper functions, filterDataByYear and getDotCount builds the array of the date + word count per year and counts the number of total days with posts in a year, respectively.
    • Line 45 -46 ensures an element called id="heatmap" exists on the page before the script runs. The renderer can be canvas or svg, and as per the documentation, both have its merits. Try both to see which works better for you.
    • The resize handler was something I had to add because of some glitches when viewing the chart on mobile devices or narrow viewports.
    • Line 57-188 configures the chart’s visual. Read the chart config documentation to see what you can change. I have used the following objects:
      • title
        • The title includes the year and dotCount
      • tooltip
      • visualMap
        • I made visualMap-piecewise. pieces labels false because it looks cluttered with the actual group values.
      • calendar
      • series
    • changeYear is set as a global function so that buttons can change the displayed year on the chart.
    • addEventListener('resize') tells EChart to recalculate the chart layout when the window changes size.
    • Line 196-204 adds interactivity by making the day squares clickable, opening the relevant post on a new tab
    • Line 210-213 are for debugging, it creates a read-only, plain-object snapshot of the data while in hugo server mode.

Step 2: Importing the EChart JavaScript Library

In order for the config in heatmap-data.html to be read and rendered into the heatmap calendar, we need to use the EChart JS Library. You can use it via a CDN (third-party reliance) or only generate the necessary code needed for our GitHub contribution style calendar using the Online EChart Builder.

I have gone down the customized route as I prefer local dependencies. On the Online Builder page, tick the following components and then click download.

  • Chart: Heatmap
  • Coordinate Systems: Calendar
  • Component: Title, Tooltip, VisualMap
  • Others: SVG Renderer (if you are using svg instead of canvas), Code Compression

Place the generated echarts.min.js into your static/js folder. The JS file is linked at the beginning of the heatmap-data.html partial with:

1<script src="{{ "/js/echarts.min.js" | relURL }}" defer></script>

If you are using it from a CDN instead, choose a source, e.g., jsdelivr:

1<script src=" https://cdn.jsdelivr.net/npm/echarts@6.0.0/dist/echarts.min.js "></script>

Step 3: Customize Appearance

heatmap.css

 1.heatmap-wrapper {
 2  margin: 0.5rem 0;
 3  overflow-x: auto;  /* Scrollbar only when needed */
 4  overflow-y: hidden;
 5  scrollbar-width: thin;
 6  scrollbar-color: var(--yellow) var(--light-grey);
 7  width: 100%;
 8  padding: 0;
 9  box-sizing: border-box;
10}
11.heatmap {
12  width: 48em;  /* Fixed width for mobile to prevent squashing */
13  min-width: 48em;  /* Ensure minimum width on mobile */
14  height: 210px;
15  box-sizing: border-box;
16}
17@media screen and (min-width: 48em) {
18  .heatmap {
19      min-width: 0;
20      width: 100%;
21      max-width: 100%;
22    }
23}
24.heatmap-year-buttons {
25  width: 100%;
26  display: grid;
27  box-sizing: border-box;
28  gap: 0.5rem;
29  margin: 0;
30  grid-auto-flow: column;
31  justify-content: inherit;
32  direction: rtl;
33}
34.heatmap-year-buttons button {
35  border-radius: 0.5rem;
36  background-color: #141414;
37  color: #DBDBDB;
38  cursor: pointer;
39  padding: 0.5rem 0;
40  margin: 0.5rem 0;
41  font-size: 0.833rem;
42  font-family: "monaspacekrypton", monospace;
43  font-weight: 600;
44  width: 100%;
45}
46/* COLORS FOR LIGHT & DARK THEMES */
47html[data-theme="dark"] .heatmap {
48  background-color: var(--bg-color);
49  border: 1px dotted var(--yellow);
50  border-radius: 0.5rem;
51}
52html[data-theme="dark"] .heatmap-year-buttons button {
53  border: 1px solid #E64814;
54}
55html[data-theme="dark"] .heatmap-year-buttons button:hover {
56  border-color: var(--red);
57  color: var(--button-state-color);
58  transition: 0.2s ease;
59}
60html[data-theme="dark"] .heatmap-wrapper {
61scrollbar-color: var(--yellow-opacity) var(--bg-color);
62}
63html[data-theme="light"] .heatmap {
64  background-color: var(--light-grey);
65  border: 1px solid var(--black);
66  border-radius: 0.5rem;
67}
68html[data-theme="light"] .heatmap-wrapper {
69  scrollbar-color: var(--special-bg) var(--bg-color);
70}
71html[data-theme="light"] .heatmap-year-buttons button {
72  border: 1px solid var(--black);
73  background-color: var(--light-grey);
74  color: var(--dark-grey);
75}
76html[data-theme="light"] .heatmap-year-buttons button:hover {
77  color: var(--special-bg);
78  transition: 0.2s ease;
79}

Update: There’s a new major upgrade for v6.0 that has new features like supporting dark mode and colour themes, so try to use the correct method from the documentation. If I get the time, I might investigate…

The final part of the equation is the CSS. Add it to your assets/css folder for Hugo Pipes processing; otherwise, put it in static/css. I have tweaked it so it looks and interacts well on both mobile and desktop; but it looks only so-so on the light theme. Using CSS to change the colours for dark and light mode is not the best/correct implementation.

Because I made this calendar heatmap before the upgrade, my method is very makeshift—the text and most of the calendar parts have hard-coded colours in the config and doesn’t respect the current theme the site is set to. I managed to make a “light theme” version by using some CSS, but it’s not the right way to do it. If you have implemented colour themes / dark mode on your EChart v6.0+, please leave a comment to share how you did it!

The rest of the CSS should make the calendar responsive and look good on all devices, with a horizontal scroll bar appearing only when it becomes too narrow to show the full year. I hope it works for you too. Please leave a message if it’s not working as expected.

Limitations

There are two issues / concerns I could think of about my guide:

  • Generating the JSON might not work well for blogs with a lot of posts—have to limit the date range I think
  • The colour themes are not integrated correctly as of the latest v6.0 update

Conclusion

I don’t know what it is about the GitHub calendar, but it was something I was always drawn to as a GitHub noob. But times have changed—I’m a SourceHut girl now! Although I have kept my GitHub account for contributing to projects still on there; I wouldn’t consider using it to host my content. Now, I don’t have to feel too nostalgic about the downfall of GitHub!

Making my first calendar heatmap and visualizing blog data was a fun and somewhat educational project—the resulting interactive calendar looks really cool and functional too (in my opinion). Leave a message below or contact me if you have any issues setting yours up, I’ll try my best to help. And I’d really appreciate if anyone spots any issues with the code—will update it right away!

Please show some support if you found my detailed guide useful; and I’d love to see your customizations!


  1. They wrote a guide on it, but I had difficulty understanding it as I don’t know any tech terms in Chinese, and sadly the translated version is a bit confusing. ↩︎

Read the original on burgeonlab.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.