htmx-echarts
v0.1.0
Published
An `htmx` extension (`echarts`) that connects `htmx`, `ECharts`, and Server-Sent Events (SSE) for live-updating (or statically-fetched) charts.
Downloads
109
Readme
HTMX Echarts
An htmx extension (echarts) that connects htmx, ECharts, and Server-Sent Events (SSE) for live-updating (or statically-fetched) charts.

This repository contains htmx-echarts extension and demo in bun. You can run demo following this steps:
To install dependencies:
bun installTo run:
bun run index.tsBesides browser-driven ECharts, the demo also contains an example of avoiding the extension by using ECharts on the server side and sending only the resulting SVG.
Pros:
- Simpler than using ECharts on the client side
- You can send the chart via email or easily put it into a PDF
Cons:
- Limited interactivity
- Live updates redraw the whole chart, which looks clunky
I wrote the extension to provide a better interactive experience for charts that update periodically. With the extension:
Pros:
- Still simple (the extension does the JS work for you)
- Rich interactivity
- Live updates behave as users expect
Cons:
- You can't send the chart via email or put it into a PDF as easily
Depending on your use case, choose the approach that fits best. You can also combine them: stream a live chart to the client with the extension, and render a server-side SVG when you need to email or export a chart.
Installation
You need:
- HTMX
- ECharts (browser bundle)
htmx-echarts.js(this extension)
Example layout head:
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Analytica</title>
{/* HTMX */}
<script src="/static/htmx.min.js" defer></script>
{/* ECharts must be loaded before the helper */}
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js"
defer
></script>
{/* Extension file */}
<script src="/static/htmx-echarts.js" defer></script>
</head>htmx-echarts.js assumes window.echarts is available.
2. Enable the extension
Activate the extension on any region that contains charts (commonly body):
<body hx-ext="echarts">
...
</body>How it works
- The extension registers as
echartsviahtmx.defineExtension("echarts", ...). - On
htmx:loadwithin anhx-ext="echarts"region, it scans the loaded fragment for[data-chart-type]and initializes charts. - On
htmx:historyRestoreit first cleans up charts in the restored fragment and then re-initializes them. - On HTMX cleanup (
htmx:beforeCleanupElement), for any subtree being removed inside anhx-ext="echarts"region, it cleans up charts. - For each chart element:
- Creates an ECharts instance on that element.
- Attaches a
ResizeObserverso the chart resizes with its container. - Reads:
data-url: endpoint for either SSE or static JSON fetch (optionally with polling modifiers).data-sse-event: when present, enables SSE streaming mode.
- Static mode (no
data-sse-event):- Parses
data-urlinto:url: the request URL.- Optional
poll: polling interval in milliseconds (see Polling below).
- Performs
fetch(url)once on initialization. - Expects the response body to be a full ECharts option object.
- Calls
chart.setOption(option)with that object. - If
pollis set, sets up asetIntervalto re-fetch the same URL everypollmilliseconds and update the chart with the latest option.
- Parses
- SSE mode (
data-sse-eventpresent):- Creates an
EventSource(url). - Listens for SSE events with the given name.
- For each event:
- Parses
event.dataas JSON. - Expects a full (or partial) ECharts option object.
- Calls
chart.setOption(option)to update the chart.
- Parses
- Creates an
- On cleanup it:
- Closes any
EventSources. - Clears any polling intervals.
- Disposes ECharts instances.
- Disconnects
ResizeObservers. - Clears internal references on the chart elements.
- Closes any
So charts are:
- Automatically initialized when they appear (initial render or HTMX swap).
- Continuously updated from SSE.
- Properly cleaned up when removed, avoiding memory and connection leaks.
Usage
Frontend: Markup
Add containers with data-chart-type and attributes for either SSE streaming, static fetch, or static fetch with polling.
The behavior is:
- If
data-urlis set anddata-sse-eventis set → SSE streaming mode. - If
data-urlis set anddata-sse-eventis not set → static fetch mode (one-shot fetch of JSON data). - If
data-urlis set,data-sse-eventis not set, anddata-urlcontains apoll:token → static fetch + polling mode (initial fetch + periodic re-fetch).
Minimal example (line chart, SSE streaming)
<section style="max-width: 640px;">
<h2>SSE ECharts (line)</h2>
<div
id="sse-chart"
data-chart-type="line"
data-url="/sse"
data-sse-event="chart-update"
style="height: 400px; border: 1px solid #eee;"
></div>
</section>Bar chart example (SSE streaming)
<section style="max-width: 640px;">
<h2>SSE ECharts (multi-series)</h2>
<div
id="sse-chart-multi"
data-chart-type="bar"
data-url="/sse-multi"
data-sse-event="chart-update"
style="height: 400px; border: 1px solid #eee;"
></div>
</section>Static chart example (one-shot fetch, no SSE)
<section style="max-width: 640px;">
<h2>Static ECharts (fetch JSON once)</h2>
<div
id="static-chart"
data-chart-type="line"
data-url="/initial-data"
style="height: 400px; border: 1px solid #eee;"
></div>
</section>In this mode:
The helper creates the ECharts instance and
ResizeObserver.It performs a single
fetch("/initial-data")on init.The JSON response must be a full ECharts option, for example:
{ "tooltip": { "trigger": "axis" }, "xAxis": { "type": "category", "data": ["Mon", "Tue", "Wed"] }, "yAxis": { "type": "value" }, "series": [ { "name": "2011", "type": "line", "data": [10, 20, 30] }, { "name": "2012", "type": "bar", "data": [5, 15, 25] } ] }and the helper will not open an SSE connection (no streaming).
Static chart with polling (periodic fetch, no SSE)
To keep a chart updated without SSE, you can encode a polling interval into data-url:
<section style="max-width: 740px;">
<h2>Line chart polling every 1000ms</h2>
<div
id="polling-chart"
data-chart-type="line"
data-url="/charts/line-polling poll:1000ms"
style="width: 100%; height: 400px; border: 1px solid #eee;"
></div>
</section>In this mode:
- The helper creates the ECharts instance and
ResizeObserver. - It performs a single
fetch("/charts/line-polling")on init. - It then re-fetches the same URL every
1000msand applies the latest option. - No SSE connection is opened.
Supported attributes:
data-chart-type(optional): chart type hint (e.g."line","bar","scatter","pie"). Helpful for semantics but not required, since the option comes from the backend.data-url(required): URL used either for SSE streaming or static JSON fetch, optionally followed by polling modifiers (see Polling below).data-sse-event(optional): when set, the helper opens anEventSourcetodata-urland listens for this SSE event name; when omitted, the helper performs afetch(url)and treats the response as static data (optionally with polling).
Polling
Polling is configured by adding whitespace-separated tokens to data-url. The first token is always treated as the actual URL; subsequent tokens may configure behavior:
poll:<duration>— enables periodic re-fetch of the chart data.
Valid duration formats:
- Plain milliseconds:
1000or1000ms - Seconds:
1s,0.5s,2.5s
Examples:
data-url="/charts/line-polling poll:1000ms"data-url="/charts/line-polling poll:1s"
Payload format (both static and SSE):
A JSON object that is a valid ECharts option, e.g.:
{ "tooltip": { "trigger": "axis" }, "xAxis": { "type": "category", "data": ["Mon", "Tue"] }, "yAxis": { "type": "value" }, "series": [ { "name": "Series A", "type": "line", "data": [1, 2] } ] }
Backend: SSE endpoint examples
Hono + Bun example (single-series)
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
const app = new Hono();
// SSE endpoint for streaming a single-series ECharts option
app.get("/sse", (c) => {
return streamSSE(c, async (stream) => {
let id = 0;
let aborted = false;
const labels: string[] = [];
const values: number[] = [];
const maxPoints = 50;
stream.onAbort(() => {
aborted = true;
console.log("SSE client disconnected");
});
while (!aborted) {
const label = new Date().toLocaleTimeString();
const value = Math.round(Math.random() * 100);
labels.push(label);
values.push(value);
if (labels.length > maxPoints) {
labels.shift();
values.shift();
}
const option = {
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: labels },
yAxis: { type: "value" },
series: [
{
name: "Random",
type: "line",
data: values,
},
],
};
await stream.writeSSE({
id: String(id++),
event: "chart-update", // matches data-sse-event
data: JSON.stringify(option),
});
await stream.sleep(1000);
}
});
});Generic Node-style example (Express-like pseudo-code)
app.get("/sse", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
let id = 0;
const labels = [];
const values = [];
const maxPoints = 50;
const interval = setInterval(() => {
const label = new Date().toISOString();
const value = Math.round(Math.random() * 100);
labels.push(label);
values.push(value);
if (labels.length > maxPoints) {
labels.shift();
values.shift();
}
const option = {
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: labels },
yAxis: { type: "value" },
series: [
{ name: "Random", type: "line", data: values },
],
};
res.write(
`id: ${id++}\n` +
`event: chart-update\n` +
`data: ${JSON.stringify(option)}\n\n`,
);
}, 1000);
req.on("close", () => {
clearInterval(interval);
});
});ASP.NET Core (C#) example
Minimal API using IResult and HttpResponse:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/sse", async context =>
{
var response = context.Response;
response.Headers.Add("Content-Type", "text/event-stream");
response.Headers.Add("Cache-Control", "no-cache");
response.Headers.Add("Connection", "keep-alive");
var id = 0;
var cancellation = context.RequestAborted;
var writer = new StreamWriter(response.Body);
while (!cancellation.IsCancellationRequested)
{
// build your option here (latest N points)
var option = new {
tooltip = new { trigger = "axis" },
xAxis = new { type = "category", data = new[] { "A", "B", "C" } },
yAxis = new { type = "value" },
series = new[] {
new { name = "Random", type = "line", data = new[] { 1, 2, 3 } }
}
};
var json = JsonSerializer.Serialize(option);
await writer.WriteAsync(
$"id: {id++}\n" +
$"event: chart-update\n" +
$"data: {json}\n\n"
);
await writer.FlushAsync();
await Task.Delay(1000, cancellation);
}
});
app.Run();This endpoint:
- Sets the correct SSE headers.
- Streams an event named
"chart-update"every second, matchingdata-sse-event="chart-update"on the frontend. - Sends payloads that are full ECharts options, which the helper applies with
setOption.
Python (Flask) example
Simple SSE endpoint using Flask:
from flask import Flask, Response
import json
import time
import random
app = Flask(__name__)
def event_stream():
event_id = 0
labels = []
values = []
max_points = 50
while True:
labels.append(time.strftime("%H:%M:%S"))
values.append(random.randint(0, 100))
if len(labels) > max_points:
labels.pop(0)
values.pop(0)
option = {
"tooltip": {"trigger": "axis"},
"xAxis": {"type": "category", "data": labels},
"yAxis": {"type": "value"},
"series": [
{"name": "Random", "type": "line", "data": values},
],
}
data = json.dumps(option)
yield (
f"id: {event_id}\n"
f"event: chart-update\n"
f"data: {data}\n\n"
)
event_id += 1
time.sleep(1)
@app.route("/sse")
def charts_sse():
return Response(event_stream(), mimetype="text/event-stream")
if __name__ == "__main__":
app.run(debug=True, threaded=True)This endpoint:
- Uses a generator (
event_stream) to yield SSE frames. - Sets
mimetype="text/event-stream"so browsers treat it as an SSE stream. - Sends
chart-updateevents with payloads that are full ECharts options.
