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).
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
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,
filterDataByYearandgetDotCountbuilds 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
yearanddotCount
- The title includes the
- tooltip
- visualMap
- I made
visualMap-piecewise. pieceslabelsfalsebecause it looks cluttered with the actual group values.
- I made
- calendar
- series
- title
changeYearis 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 servermode.
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!



Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.