Instead of using an open source solution (like Chart.js) and adding yet another dependency to this site, I created my own reusable bar chart component for this static site!
Here it is in action:
As an added bonus, no JavaScript is used!
I'm using Eleventy but the process should be similar for other static site generators.
Instead of using shortcodes, I prefer this approach which I've used before:
- We'll create a
barchart.njk
template inside of my Eleventy-specific_includes/
folder - Whenever we want to add a bar chart, we define variables for the title, data, and axis labels
- We then
include
ourchart.njk
template so it inherits the title, data, and axis labels we just defined
This is the Eleventy and Nunjucks way of things, but the process should be similar for other static site generators.
Here's an example of what the above bar chart looks like in my markdown files:
{% set chart_data = [{"x":"Bar 1","y":10},{"x":"Bar 2","y":1},{"x":"Bar 3","y":5}] %}
{% set chart_title = "Example Bar Chart" %}
{% set chart_ylabel = "Y Axis Label" %}
{% set chart_xlabel = "X Axis Label" %}
{% include 'components/barchart.njk' %}
Just remember to redefine each variable before you include
the component again or it might inherit values set for an earlier chart!
Changing the folder path to barchart.njk
will break this implementation, but a careful find and replace should serve you well.
Here's the idea: the entire bar chart uses HTML and CSS. No JavaScript.
We can have each bar be a <div>
with a percent width. For example:
<p>Here are some bars of different widths.</p>
<div class="container">
<div class="bar" style="width: 100%;"></div>
<div class="bar" style="width: 10%;"></div>
<div class="bar" style="width: 50%;"></div>
</div>
.container {
width: 300px;
position: relative;
border: 3px solid #FF0000;
}
.bar {
height: 20px;
margin: 10px 0;
background-color: #0000FF;
}
false
While horizontal bars look neat, we can also make them vertical:
<p>Here are some bars of different heights.</p>
<div class="container">
<div class="bar" style="height: 100%;"></div>
<div class="bar" style="height: 10%;"></div>
<div class="bar" style="height: 50%;"></div>
</div>
.container {
width: 100px;
height: 100px;
position: relative;
border: 3px solid #FF0000;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 20px;
align-items: flex-end;
}
.bar {
background-color: #0000FF;
}
false
Unfortunately, this is the easy part when it comes to the frontend. We might also want to calculate x or y axis numbers, which require a bit more work.
This section is only needed if you want to include numbers on your y-axis (or x-axis if your bars are horizontal).
The easiest thing to do would be to pass in these numbers for every use of this component. But instead, I decided to calculate these based on the largest and smallest data points.
So, for example, if the largest data point has a value of 100 and the lowest has a value of 0 and we want 11 axis numbers, the axis would look like this:
<div class="container">
<div>100</div>
<div>90</div>
<div>80</div>
<div>70</div>
<div>60</div>
<div>50</div>
<div>40</div>
<div>30</div>
<div>20</div>
<div>10</div>
<div>0</div>
</div>
.container {
border-right: 1px solid #7E7E7E;
display: grid;
grid-template-columns: 1fr;
gap: 10px;
width: fit-content;
}
.container div {
display: flex;
align-items: center;
justify-content: end;
}
.container div::after {
content: "";
display: block;
height: 1px;
width: 5px;
background-color: #7E7E7E;
}
false
To calculate these numbers, we can use a simple linspace function:
function linspace(startValue, stopValue, skip) {
var arr = [];
var step = (stopValue - startValue) / (skip - 1);
for (var i = 0; i < skip; i++) {
arr.push(startValue + (step * i));
}
return arr;
}
Try this interactive demo to see it in action:
<form onsubmit="linspace(); return false;">
<div><label for="startVlue"><code>startValue</code>:</label><input name="startValue" id="startValue" value="0"></div>
<div><label for="stopValue"><code>stopValue</code>:</label><input name="stopValue" id="stopValue" value="100"></div>
<div><label for="skip"><code>skip</code>:</label><input name="skip" id="skip" value="11"></div>
<input type="submit">
</form>
<p id="output">Data will be output here.</p>
form {
display: grid;
grid-template-columns: fit-content;
gap: 10px;
}
form div {
display: grid;
grid-template-columns: 1fr 2fr;
}
function linspace() {
startValue = document.getElementById("startValue").value;
stopValue = document.getElementById("stopValue").value;
skip = document.getElementById("skip").value;
var arr = [];
var step = (stopValue - startValue) / (skip - 1);
for (var i = 0; i < skip; i++) {
arr.push(parseFloat(startValue + (step * i)));
}
document.getElementById("output").innerHTML = arr.join(", ");
}
Looks fine so far, right? Well, try inputting a different stopValue
, say, 101
? The output will look like:
0, 10.1, 20.2, 30.299999999999997, 40.4, 50.5, 60.599999999999994, 70.7, 80.8, 90.89999999999999, 101
Yeah, not pretty. Fortunately, we can get around this by rounding the numbers using Math.round()
.
<form onsubmit="linspace(); return false;">
<div><label for="startVlue"><code>startValue</code>:</label><input name="startValue" id="startValue" value="0"></div>
<div><label for="stopValue"><code>stopValue</code>:</label><input name="stopValue" id="stopValue" value="101"></div>
<div><label for="skip"><code>skip</code>:</label><input name="skip" id="skip" value="11"></div>
<input type="submit">
</form>
<p id="output">Data will be output here.</p>
form {
display: grid;
grid-template-columns: fit-content;
gap: 10px;
}
form div {
display: grid;
grid-template-columns: 1fr 2fr;
}
function linspace() {
startValue = document.getElementById("startValue").value;
stopValue = document.getElementById("stopValue").value;
skip = document.getElementById("skip").value;
var arr = [];
var step = (stopValue - startValue) / (skip - 1);
for (var i = 0; i < skip; i++) {
arr.push(Math.round(parseFloat(startValue + (step * i))));
}
document.getElementById("output").innerHTML = arr.join(", ");
}
That solves one problem but leaves us with another. Let's say instead of the largest data point being 100, it was 5. If you try it in the above demo, you will get this output:
0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5
So we also want to remove duplicates. Finally, this demo will work:
<form onsubmit="linspace(); return false;">
<div><label for="startVlue"><code>startValue</code>:</label><input name="startValue" id="startValue" value="0"></div>
<div><label for="stopValue"><code>stopValue</code>:</label><input name="stopValue" id="stopValue" value="5"></div>
<div><label for="skip"><code>skip</code>:</label><input name="skip" id="skip" value="11"></div>
<input type="submit">
</form>
<p id="output">Data will be output here.</p>
form {
display: grid;
grid-template-columns: fit-content;
gap: 10px;
}
form div {
display: grid;
grid-template-columns: 1fr 2fr;
}
function linspace() {
startValue = document.getElementById("startValue").value;
stopValue = document.getElementById("stopValue").value;
skip = document.getElementById("skip").value;
var arr = [];
var step = (stopValue - startValue) / (skip - 1);
for (var i = 0; i < skip; i++) {
arr.push(Math.round(parseFloat(startValue + (step * i))));
}
unique_arr = [...new Set(arr)];
document.getElementById("output").innerHTML = unique_arr.join(", ");
}
Sometimes, the numbers won't be evenly spaced. Try using a stopValue
of 13
for example:
0, 1, 3, 4, 5, 7, 8, 9, 10, 12, 13
Notice how the difference between 1
and 3
is two but the difference between 3
and 4
is one.
We want to use a skip
of 11
instead of 10
. If we use 10
, the output might look off. For startValue
of 0
and stopValue
of 100
, a skip
of 10
will produce this output:
0, 11, 22, 33, 44, 56, 67, 78, 89, 100
And that doesn't look nearly as good as a skip
of 11
:
0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100
Since I'm using Eleventy, I chose to chuck this function into a filter:
eleventyConfig.addFilter("linspace", (startValue, stopValue, skip) => {
var arr = [];
var step = (stopValue - startValue) / (skip - 1);
for (var i = 0; i < skip; i++) {
arr.push(startValue + (step * i));
}
return arr;
});
The easiest approach is to simply add an overflow-x: auto;
wrapper element.
<div style="overflow-x:auto;">
<div class="the-bar-chart">
...
</div>
</div>
I coded it such that the y-axis is fixed when scrolling, but that's far too advanced for this article. But feel free to give it a try if you're up for the challenge!
Feel free to inspect element this site to figure out how I did it. Just forgive my terrifying code...
Once we have the HTML and CSS for the chart created, we need to pass in our data whenever we include
the component, like so:
{% set chart_data = [{"x":"Bar 1","y":10},{"x":"Bar 2","y":1},{"x":"Bar 3","y":5}] %}
{% set chart_title = "Example Bar Chart" %}
{% set chart_ylabel = "Y Axis Label" %}
{% set chart_xlabel = "X Axis Label" %}
{% include 'components/barchart.njk' %}
Our barchart.njk
template can then populate our HTML. For example:
<div class="bars">
{% for datum in chart_data %}
<div class="bar" style="width: {{ (datum.y / max) * 100 }}%"></div>
{# max can be previously defined to be our largest data point #}
{% endfor %}
</div>
Eleventy in specific is picky when you include
HTML code in Markdown files. If there is any blank lines, it won't render properly.
Since the above code will render with blank lines, it might cause problems. Instead, avoid putting any Nunjucks code on their own lines. So this should work:
<div class="bars">
{% for datum in chart_data %}<div class="bar" style="width: {{ (datum.y / max) * 100 }}%"></div>{% endfor %}
</div>
There is certainly a better way to remove blank lines, maybe with a filter. But this is the easiest way.
For accessibility, I added the raw data to a regular HTML list and hid it with a visually-hidden class:
<div class="visually-hidden">
<ul>
{% for datum in chart_data %}<li>{{datum.x}}: {{ datum.y }}</li>{% endfor %}
</ul>
</div>
When it comes to RSS, I run my articles through a filter to strip custom components like this. It could then be replaced with the raw data or a link to the article.
And there you go! Now you know everything you need to make your own bar chart component. I never expected this feature to be this complicated when I started it, but here we are.
If you have any questions, leave a comment below or send me an email.