Skip to content

Chart

The chart recipe turns a server-rendered data table into an Observable Plot SVG chart. The table is the data source, the no-JavaScript fallback, and the screen-reader data — installChart() reads it and draws the chart. No per-chart JavaScript: the chart type and per-series marks are declared in markup.

This recipe needs a behaviorinstallChart — and Observable Plot, an optional peer dependency you load yourself. Plot is never bundled into @hypermedia-components/core, so installChart is not part of the auto-init behaviors entry. Without Plot the behavior is a no-op and the table stays visible.

Also known as: graphs, data visualization.

Switch regions — each button refetches the <figure class="hc-chart"> fragment (a semantic data table) from a real endpoint under api/recipes/chart/, and installChart redraws it on htmx:load. Without JavaScript the same endpoint serves the table as a readable page — the table is the fallback.

Monthly sales
MonthSales
Jan120
Feb200
Mar150

Load Plot (here the UMD global from a CDN) and install once per page:

<script src="https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/dist/plot.umd.min.js"></script>
<script type="module">
import { installChart } from '@hypermedia-components/core';
installChart(document, { plot: window.Plot });
</script>

installChart picks up window.Plot automatically, so installChart() alone also works once the CDN script has run. Passing plot explicitly is clearer and lets you use a bundled import instead of the global.

For categorical charts the table is read as:

  • Column 1 — the x category.
  • Columns 2…N — one series each; the <thead> cell is the series name.
  • Each <td> is coerced to a number (1,2001200; currency and % signs are stripped).
  • An optional <caption> becomes the chart title.

Set data-hc-chart on the figure:

ValueRenders
barVertical bars. Multiple series stack.
lineLines with node dots, one per series.
areaFilled areas with a line edge, one per series.
comboPer-column marks — set <th data-mark="bar|line|area">.

data-hc-chart is the default mark for any column without its own data-mark. bar / line / area are just the case where every column shares one mark.

Weekly sessions
WeekWebMobile
W14218
W24824
W33931
W45536
W56133
Storage used
MonthUsed
Jan120
Feb135
Mar170
Apr165
May210

Declare the mark per column with data-mark. The default for unmarked columns under combo is bar.

Monthly sales vs target
MonthSalesTarget
Jan120150
Feb200160
Mar150170

Plot places the line on the bars’ band scale automatically, so the points align to the bar centres. When series share a y range this needs no extra configuration; a true secondary y-axis is out of scope for this recipe.

Stacked, grouped, horizontal, scatter, sparkline

Section titled “Stacked, grouped, horizontal, scatter, sparkline”

Six whole-figure presets extend the same table contract (per-column data-mark combos don’t apply to them).

Multiple series stack (the explicit form of what multi-series bar already does).

Orders by channel
QuarterStoreOnline
Q18045
Q29570
Q36098

Series render side-by-side within each category; the category axis carries the labels and the legend names the series.

Orders by channel
QuarterStoreOnline
Q18045
Q29570
Q36098

The ranking shape: categories go on y — long labels stay readable — and values on x; multiple series stack. data-y-label, data-y-min / data-y-max and data-y-format configure the value axis (which is x here), and a bare data-tip snaps along the category axis.

Top products
ProductSales
Wireless headphones320
Mechanical keyboard260
USB-C dock210
Laptop stand150
Webcam90

Series render side-by-side within each category, horizontally (faceted on the row axis).

Orders by channel
QuarterStoreOnline
Q18045
Q29570
Q36098

Two numeric axes: the first column is a numeric x (data-x-type defaults to number), each series column is one dot set, and an optional <th data-role="r"> column drives the dot radius.

Height vs weight
HeightWeightCount
150523
158586
1656312
172709
180784

A compact Plot-styled trend: axes, grid and legend off, 48 px tall unless data-height says otherwise. For a dependency-free inline trend, the standalone sparkline component is usually the better fit — this preset exists for Plot-consistent dashboards.

30-day trend
DayValue
112
218
39
422
517
628
725

Bins one numeric column into count bars (data-x-type defaults to number; extra columns are ignored; data-bins caps the bin count).

Response times (ms)
ms
90
95
110
120
125
140
160
180
210
260
340
520

The matrix shape: row categories on y, column headers on x, cell values drive a continuous fill with a color legend (data-scheme picks the Plot scheme; the categorical series palette does not apply). Row/column order is preserved as authored.

Visits by slot
SlotMonTueWedThuFri
Morning37584
Afternoon64957
Evening926310

The financial bridge: one signed-delta column, and bars float from the running total before each delta to the total after it. Rows marked <tr data-total> are absolute anchors — the cell holds the real total (the no-JavaScript table stays truthful), the bar runs from 0 to that value, and the running total resets to it. Increase / decrease / total bars are coloured by --hc-chart-waterfall-increase / -decrease / -total (success / error / muted by default).

Cash bridge
StepAmount
Opening100
Sales+80
Refunds-15
Costs-30
Closing135

With data-tip the tooltip shows the step, the running total and the delta. The legend (increase / decrease / total) is on by default; data-legend="false" hides it.

Add data-tip to show a hover tooltip — the x value, the y value, and (with multiple series) the series name. Exactly one tooltip shows at a time, even on combo charts. A bare data-tip snaps along x (best for bars, lines and time series); scatter defaults to nearest-point (xy). Set data-tip="x", "y" or "xy" to pick explicitly. histogram tips show the bin range and count; heatmap tips show row × column + value. Hover the demo:

Weekly sessions
WeekWebMobile
W14218
W24824
W33931
W45536
W56133

Tooltips are client-side interactivity — they do not apply to the server-rendered path.

Make the first-column cell a real link and add data-link — clicking a bar, dot or cell forwards the click to that row’s anchor. The chart never owns a URL or a request: a plain href navigates, and htmx attributes on the anchor (data-hx-get, data-hx-target, data-hx-push-url, …) swap exactly as authored. The same link is the no-JavaScript, keyboard, and screen-reader path, since the table stays the accessible surface.

Sales by product (click a bar)
ProductSales
Alpha320
Beta180
Gamma240

Rows without an anchor simply don’t navigate. Focus tracking reuses the tooltip’s pointer (or an invisible probe without data-tip); clicks in empty space — beyond the pointer’s 40 px radius — do nothing, and the cursor becomes a pointer only while a clickable datum is focused. Client-side only, and not supported for histogram (bins aggregate many rows).

Figure-wide form mode (category × series)

Section titled “Figure-wide form mode (category × series)”

Row anchors carry row granularity. To let the server decide what a click means — and to know which series was clicked in a multi-series chart — put a <form> in the figure instead. On click, named fields matching the datum keys (x, series, value, …) are filled from the focused datum and the form is submitted, so htmx on the form owns the request:

<figure class="hc-chart" data-hc-chart="bar-grouped" data-link data-tip>
<form data-hx-get="/reports/drill" data-hx-target="#detail">
<input type="hidden" name="x">
<input type="hidden" name="series">
</form>
<table class="hc-table"></table>
</figure>
<!-- click Feb × Osaka → GET /reports/drill?x=Feb&series=Osaka -->

A <form> takes precedence over row anchors and makes every datum clickable. For a no-JavaScript drill-down path, use visible fields (a <select name="x"> + submit button) instead of hidden ones — the same endpoint serves both.

data-y-min / data-y-max pin the y domain — fix percentages to 0–100, align axes across dashboard charts, or start a line chart above zero (the zero baseline rule is dropped automatically when 0 leaves the domain). data-y-format formats the ticks with a d3-format string ("s"1.2k, ".0%", ",.0f").

Monthly revenue
MonthRevenue
Jan4,200
Feb4,650
Mar4,400
Apr5,100
May5,600

With stacked bars set both bounds explicitly — the fallback extent reads raw cell values and ignores stacking.

AttributeDefaultEffect
data-y-label(none)y-axis label.
data-title<caption>Chart title.
data-x-typecategorycategory | number | date.
data-widthcontainer widthPlot width (px).
data-height--hc-chart-height (320px)Plot height (px).
data-legendauto (on for ≥2 series)false hides the colour legend.
data-tipoffHover tooltip; bare, or x | y | xy.
data-y-min / data-y-maxdata extentPin the y domain.
data-y-formatPlot defaulty tick format (d3-format string).
data-linkoffClicks forward to the row’s first-column anchor.

Bars expect a category x; number / date x suit line / area.

The attributes cover the high-frequency needs; for anything else pass buildOptions to installChart — it receives the final Plot spec and the figure just before rendering, and whatever it returns is what Plot draws:

installChart(document, {
plot: window.Plot,
buildOptions: (spec, figure) => ({
...spec,
marginLeft: 60,
y: { ...spec.y, ticks: 4 },
}),
});

Returning nothing keeps the built spec, so a hook can also just observe.

Chart chrome (ticks, axis labels, grid) follows the theme via --hc-chart-axis and --hc-chart-grid. Series colours come from a fixed categorical palette, --hc-chart-series-1--hc-chart-series-6, resolved at render time and handed to Plot’s colour scale. The palette is deliberately theme-independent — chart series must stay mutually distinguishable rather than track the brand accent.

:root {
--hc-chart-series-1: #2563eb; /* override per series */
}

installChart listens for htmx:load, so a chart swapped into the page renders automatically — no per-swap JavaScript.

<div data-hx-get="/reports/sales" data-hx-trigger="load" data-hx-swap="innerHTML">
<!-- The server returns the <figure class="hc-chart">…</figure> fragment. -->
</div>

Return the same readable table for a non-htmx request (a full page load) so the no-JavaScript path works. Detect htmx via the HX-Request: true header if you wrap fragments in a layout.

  • The source table is kept — moved into the accessibility tree with .hc-sr-only, not removed — so assistive tech reads the full data.
  • The rendered <svg> is aria-hidden="true": it is a decorative duplicate of the table, so it is not announced twice.
  • Always give the table a <caption> describing the chart.
  • No JavaScript → the <table class="hc-table"> renders as a normal, readable table.
  • JavaScript, no Plot → same: installChart is a no-op without Plot.
  • JavaScript + Plot → the table moves into the accessibility tree and the SVG chart is shown.

Charts can also be rendered to SVG on the server with Plot under a DOM shim (linkedom) and returned inline, with no client Plot. Set explicit marginLeft / marginBottom then — server DOM shims do not measure text for automatic axis margins — and pass explicit colors (tokens are client-side CSS). Emit the figure with data-state="rendered" (plus the hc-sr-only table and the aria-hidden SVG): installChart() recognizes already-rendered figures and leaves them alone, so SSR’d and client-rendered charts coexist. The contract has the working snippet.

  • Table — the semantic table the chart reads from.
  • Data region — refresh a region (and its chart) in response to events.