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.

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.

Four 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

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
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.

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

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.