htmx-echarts
v0.2.0
Published
An `htmx` extension (`echarts`) that connects `htmx`, `ECharts`, and Server-Sent Events (SSE) for live-updating (or statically-fetched) charts.
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 (local bundle):
<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>
/* or cnd */
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx-echarts.min.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>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.
Empty state (“no data”) via graphic
When there is nothing to plot, your API can still return a full ECharts option: keep axes/series (with empty data) and add a graphic text element so the chart area shows a message instead of an empty grid.
{
"tooltip": { "trigger": "axis" },
"xAxis": { "type": "category", "data": [] },
"yAxis": { "type": "value" },
"series": [{ "name": "Example", "type": "bar", "data": [] }],
"graphic": {
"type": "text",
"left": "center",
"top": "middle",
"style": {
"text": "No data available",
"fontSize": 16,
"fill": "#999"
}
}
}Markup is the same as any other static fetch:
<div
data-chart-type="bar"
data-url="/charts/empty-placeholder"
style="height: 400px; border: 1px solid #eee;"
></div>The demo app serves that JSON from GET /charts/empty-placeholder so you can try it without wiring your own empty-state branch yet.
Supported attributes:
data-chart-type(required): marks the element as a chart container — the extension uses[data-chart-type]as its selector. The value (e.g."line","bar","pie") is not read by the extension but is useful for readability; without the attribute the element is ignored entirely.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).data-theme(optional): registered ECharts theme name passed toecharts.init(see Theme below). Omit for the default look.data-chart-bridge(optional): which ECharts events to forward to HTMX —click,hover/mouseover, orfalse/none(see Chart events below).data-chart-event-click/data-chart-event-hover(optional): custom event names instead ofchart-click/chart-hover.data-chart-loading(optional): set to"false"to suppress the built-in loading spinner. By default the extension callschart.showLoading()immediately after init andchart.hideLoading()once the first data arrives (fetch response or first SSE event).
Theme
The extension reads data-theme and passes it as the theme argument to echarts.init(dom, theme). That name must already be registered on window.echarts. The main echarts.min.js bundle alone does not register built-in themes such as dark; you load them as extra scripts (or call echarts.registerTheme yourself).
Load theme files from the same ECharts version you use for the core bundle, after echarts.min.js and before htmx-echarts.js:
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js"
defer
></script>
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/theme/dark.js"
defer
></script>
<script src="/static/htmx-echarts.js" defer></script>Example chart using the dark theme:
<div
data-chart-type="line"
data-url="/charts/line-polling poll:1000ms"
data-theme="dark"
style="height: 400px; border: 1px solid #eee;"
></div>Bundled theme scripts live under echarts/theme/ on the CDN (for example macarons.js, vintage.js). Each file calls echarts.registerTheme with a fixed name; use that exact string in data-theme.
For a custom theme, run echarts.registerTheme("myTheme", { /* theme object */ }) before htmx-echarts.js executes, then set data-theme="myTheme" on the chart element.
The official theme download / gallery page lists more options and includes the theme builder; nothing in this extension stops you from registering a theme you export from there (same registerTheme + data-theme flow as any other custom theme).
Chart events (HTMX bridge)
ECharts user events are forwarded to the chart container as DOM events via htmx.trigger, so you can drive HTMX from the chart without extra glue code (for example hx-trigger="chart-click" on the same element to load a detail table after a bar click).
By default the extension listens for:
Each event’s detail is a plain object with useful fields from ECharts’ callback argument when present, for example name, value, seriesIndex, dataIndex, seriesName, componentType, data, and color.
Example: load a fragment when a slice is clicked
<div
data-chart-type="pie"
data-url="/api/charts/sales-by-region"
hx-trigger="chart-click"
hx-get="/api/details"
hx-target="#details"
></div>event.detail matches ECharts’ callback argument (e.g. name, value). To send those fields as parameters, use hx-vals with a js: expression (for example hx-vals=’js:{ region: event.detail.name }’) or handle the event with hx-on:chart-click and call htmx.ajax yourself, depending on your HTMX version and needs.
Inline logging (devtools)
<div
data-chart-type="pie"
data-url="/api/charts/sales-by-region"
hx-on:chart-click="console.log(‘chart-click’, event.detail)"
hx-on:chart-hover="console.log(‘chart-hover’, event.detail)"
></div>Optional attributes
data-chart-bridge— comma-separated list:click,hover(ormouseover). Defaults toclick,hover. Usefalseornoneto disable bridging.data-chart-event-click— override the click event name (defaultchart-click).data-chart-event-hover— override the hover event name (defaultchart-hover).
If htmx.trigger is not available, the extension dispatches a bubbling CustomEvent with the same name and detail instead.
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 native SSE support (TypedResults.ServerSentEvents):
using System.Net;
using System.Runtime.CompilerServices;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/sse", (CancellationToken cancellationToken) =>
{
async IAsyncEnumerable<SseItem<object>> GetChartUpdates(
[EnumeratorCancellation] CancellationToken ct)
{
var id = 0;
while (!ct.IsCancellationRequested)
{
// Build your option here (e.g. 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 } }
}
};
yield return new SseItem<object>(option, eventType: "chart-update")
{
EventId = (id++).ToString()
};
await Task.Delay(1000, ct);
}
}
return TypedResults.ServerSentEvents(GetChartUpdates(cancellationToken));
});
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.
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.data-theme: optional registered theme name forecharts.init(see Theme).data-chart-bridge/data-chart-event-click/data-chart-event-hover: optional wiring for ECharts → HTMX events (see Chart events).data-chart-loading: when not"false", callschart.showLoading()immediately andchart.hideLoading()once the first data arrives.
- Subscribes to ECharts
clickandmouseover(unless disabled) and forwards them withhtmx.triggeron the chart element aschart-clickandchart-hover(or custom names). - 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.
