How to Add Interactive Charts to Jekyll Chirpy with Chart.js
This guide shows how to add interactive Chart.js charts to your Jekyll Chirpy theme — supporting both inline data and CSV files, with a clean Liquid tag to place charts anywhere in your post. No external plugins required beyond one small Ruby file.
We’ll use real Singapore HDB resale data (2017–2026) to demonstrate every feature.
About the Demo Dataset
The charts in this post use the Resale Flat Prices dataset from data.gov.sg — Singapore’s open data portal. The raw dataset contains over 226,000 transactions from January 2017 onwards, with fields like month, town, flat_type, floor_area_sqm, and resale_price.
Since Chart.js renders in the browser, loading 226K rows directly isn’t practical. For this demo, the raw data was pre-aggregated into three small CSV files:
| File | What it contains | Rows |
|---|---|---|
hdb-yearly-avg.csv |
Average resale price and transaction count per year | 10 |
hdb-town-top10.csv |
Average price for the top 10 towns by volume | 10 |
hdb-flattype-avg.csv |
Average price per flat type | 7 |
This is the typical workflow for Chart.js: prepare small, aggregated datasets rather than feeding in raw data.
Live Demo
Before we get into the implementation, here are the charts this integration produces — all powered by the HDB resale dataset above.
HDB Resale Price Trend (CSV — Line Chart)
Prices have risen steadily since 2020, crossing the $650K average in 2025.
Top 10 Towns by Volume (CSV — Bar Chart)
Sengkang and Punggol lead in transaction volume — both are newer towns with high turnover.
Average Price by Flat Type (CSV — Bar Chart)
The jump from 5 ROOM to EXECUTIVE is significant — executive flats command a premium due to larger floor area and additional features.
Flat Type Distribution (Inline Data — Doughnut Chart)
4 ROOM flats dominate the resale market, accounting for nearly half of all transactions.
How It Works
The integration uses three files working together:
| File | Role |
|---|---|
_includes/metadata-hook.html |
Detects chart config in front matter, loads Chart.js CDN and renderer |
_plugins/posts-chart-tag.rb |
Provides {% chart %} Liquid tag for placing charts in posts |
assets/js/chart-renderer.js |
Reads config, fetches CSV if needed, renders Chart.js canvas |
Data flow:
1
Front matter (chart/charts) → metadata-hook.html (jsonify) → window.chartConfig → chart-renderer.js → Chart.js canvas
Posts without chart: or charts: in the front matter load zero additional JS.
Step 1 — Create the Chart Renderer
Create assets/js/chart-renderer.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
(function () {
var configs = window.chartConfig;
if (!configs || !configs.length) return;
var chartDivs = document.querySelectorAll("[data-chart]");
if (!chartDivs.length) return;
// --- CSV Parser ---
function parseCSV(text) {
var lines = text.trim().split("\n");
var headers = lines[0].split(",").map(function (h) {
return h.trim().replace(/^["']|["']$/g, "");
});
var rows = [];
for (var i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
var values = lines[i].split(",").map(function (v) {
return v.trim().replace(/^["']|["']$/g, "");
});
var row = {};
headers.forEach(function (h, j) {
row[h] = values[j];
});
rows.push(row);
}
return { headers: headers, rows: rows };
}
// --- Build Chart.js data from config ---
function buildChartData(config, csvData) {
if (csvData) {
var labels = csvData.rows.map(function (r) {
return r[config.xAxis];
});
var yColumns = Array.isArray(config.yAxis)
? config.yAxis
: [config.yAxis];
var datasets = yColumns.map(function (col) {
return {
label: col,
data: csvData.rows.map(function (r) {
return parseFloat(r[col]);
}),
};
});
return { labels: labels, datasets: datasets };
}
var data = config.data;
return {
labels: data.labels,
datasets: data.datasets.map(function (ds) {
return { label: ds.label, data: ds.values };
}),
};
}
// --- Render a single chart ---
function renderChart(div, config, csvData) {
var canvas = document.createElement("canvas");
div.appendChild(canvas);
var chartData = buildChartData(config, csvData);
var options = { responsive: true, plugins: {} };
if (config.title) {
options.plugins.title = { display: true, text: config.title };
}
new Chart(canvas, {
type: config.type || "bar",
data: chartData,
options: options,
});
}
// --- Process: fetch CSV if needed, then render ---
function processChart(div, config) {
if (typeof config.data === "string") {
fetch(config.data)
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
})
.then(function (text) {
renderChart(div, config, parseCSV(text));
})
.catch(function (err) {
div.textContent = "Error loading chart data: " + err.message;
});
} else {
renderChart(div, config, null);
}
}
// --- Match divs to configs and render ---
for (var i = 0; i < chartDivs.length; i++) {
var div = chartDivs[i];
var id = div.getAttribute("data-chart");
var config;
if (!id || id === "") {
config = configs[0];
} else {
for (var j = 0; j < configs.length; j++) {
if (configs[j].id === id) {
config = configs[j];
break;
}
}
}
if (config) processChart(div, config);
}
})();
Step 2 — Create the Liquid Tag Plugin
Create _plugins/posts-chart-tag.rb:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module Jekyll
class ChartTag < Liquid::Tag
def initialize(tag_name, markup, tokens)
super
@chart_id = markup.strip
end
def render(context)
if @chart_id.empty?
'<div data-chart></div>'
else
%(<div data-chart="#{@chart_id}"></div>)
end
end
end
end
Liquid::Template.register_tag('chart', Jekyll::ChartTag)
This converts {% chart %} into <div data-chart></div> and {% chart myid %} into <div data-chart="myid"></div>.
Step 3 — Edit the Metadata Hook
Add this block to _includes/metadata-hook.html:
1
2
3
4
5
6
7
{% if page.chart or page.charts %}
<script>
window.chartConfig = {% if page.charts %}{{ page.charts | jsonify }}{% else %}[{{ page.chart | jsonify }}]{% endif %};
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="/assets/js/chart-renderer.js" defer></script>
{% endif %}
This does three things:
- Normalises
chart:(single) andcharts:(multiple) into a single array onwindow.chartConfig - Loads Chart.js 4.x from jsdelivr CDN
- Loads the renderer script (deferred)
Usage Examples
Single chart — inline data
1
2
3
4
5
6
7
8
9
10
---
chart:
type: bar
title: "Quarterly Revenue"
data:
labels: ["Q1", "Q2", "Q3", "Q4"]
datasets:
- label: "Revenue ($K)"
values: [120, 150, 180, 210]
---
1
{% chart %}
Single chart — CSV file
1
2
3
4
5
6
7
8
---
chart:
type: line
title: "HDB Resale Prices"
data: /assets/data/hdb-yearly-avg.csv
xAxis: year
yAxis: avg_price
---
1
{% chart %}
Multiple charts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
---
charts:
- id: prices
type: line
title: "Price Trend"
data: /assets/data/hdb-yearly-avg.csv
xAxis: year
yAxis: avg_price
- id: towns
type: bar
title: "Top Towns"
data: /assets/data/hdb-town-top10.csv
xAxis: town
yAxis: avg_price
---
1
2
3
{% chart prices %}
{% chart towns %}
Multiple Y-axis columns from CSV
1
2
3
4
5
6
7
---
chart:
type: line
data: /assets/data/stocks.csv
xAxis: date
yAxis: [aapl, goog, msft]
---
This renders three lines on the same chart, one per column.
Config Reference
| Option | Required | Applies to | Description |
|---|---|---|---|
type |
Yes | Both | Chart type: bar, line, pie, doughnut
|
title |
No | Both | Title displayed above the chart |
data |
Yes | Both | CSV file path (string) or inline data (object) |
xAxis |
Yes | CSV only | CSV column name for the x-axis |
yAxis |
Yes | CSV only | CSV column name(s) for the y-axis — string or array |
id |
Yes | Multiple charts | Unique identifier to match {% chart id %}
|
Inline data structure
1
2
3
4
5
data:
labels: ["Label 1", "Label 2", "Label 3"]
datasets:
- label: "Dataset Name"
values: [10, 20, 30]
Supported chart types
| Type | Best for |
|---|---|
bar |
Comparing categories |
line |
Trends over time |
pie |
Proportions (single dataset) |
doughnut |
Proportions with centre space |
All types supported by Chart.js 4.x work — including radar, polarArea, scatter, and bubble.
CSV Format
The CSV parser expects:
- First row as headers
- Comma-separated values
- No complex quoting (simple values only)
Example hdb-yearly-avg.csv:
year,avg_price,transactions
2017,443889,20509
2018,441282,21561
2019,432138,22186
2020,452279,23333
2021,511381,29087
2022,549714,26720
2023,571806,25754
2024,612597,27832
2025,652487,25089
2026,655286,4672
Disabling Charts
To remove charts from a post, delete the chart: or charts: block from the front matter. No Chart.js code will load on that page.
Why This Approach
-
No build-time dependencies — Chart.js loads from CDN, no
npm installneeded - Config-driven — all chart setup lives in front matter YAML, no JS editing needed per post
- Conditional loading — Chart.js only loads on pages that use charts
- Two data sources — inline YAML for small datasets, CSV files for larger ones
-
Placement control —
{% chart id %}lets you put charts anywhere in your post with commentary around them - Same pattern — follows the same architecture as the map integration (front matter + plugin + renderer)
