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 behavior —
installChart —
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.
Short form
Section titled “Short form”| Month | Sales |
|---|---|
| Jan | 120 |
| Feb | 200 |
| Mar | 150 |
<figure class="hc-chart" data-hc-chart="bar" data-y-label="Sales ($k)"> <table class="hc-table"> <caption>Monthly sales</caption> <thead><tr><th>Month</th><th>Sales</th></tr></thead> <tbody> <tr><td>Jan</td><td>120</td></tr> <tr><td>Feb</td><td>200</td></tr> <tr><td>Mar</td><td>150</td></tr> </tbody> </table></figure>Install the behavior
Section titled “Install the behavior”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.
The table contract
Section titled “The table contract”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,200→1200; currency and%signs are stripped). - An optional
<caption>becomes the chart title.
Chart types
Section titled “Chart types”Set data-hc-chart on the figure:
| Value | Renders |
|---|---|
bar | Vertical bars. Multiple series stack. |
line | Lines with node dots, one per series. |
area | Filled areas with a line edge, one per series. |
combo | Per-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.
Combo charts (bar + line)
Section titled “Combo charts (bar + line)”Declare the mark per column with data-mark. The default for unmarked
columns under combo is bar.
| Month | Sales | Target |
|---|---|---|
| Jan | 120 | 150 |
| Feb | 200 | 160 |
| Mar | 150 | 170 |
<figure class="hc-chart" data-hc-chart="combo" data-y-label="Sales ($k)"> <table class="hc-table"> <caption>Monthly sales vs target</caption> <thead> <tr> <th>Month</th> <th data-mark="bar">Sales</th> <th data-mark="line">Target</th> </tr> </thead> <tbody> <tr><td>Jan</td><td>120</td><td>150</td></tr> <tr><td>Feb</td><td>200</td><td>160</td></tr> <tr><td>Mar</td><td>150</td><td>170</td></tr> </tbody> </table></figure>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.
Options
Section titled “Options”| Attribute | Default | Effect |
|---|---|---|
data-y-label | (none) | y-axis label. |
data-title | <caption> | Chart title. |
data-x-type | category | category | number | date. |
data-width | container width | Plot width (px). |
data-height | --hc-chart-height (320px) | Plot height (px). |
data-legend | auto (on for ≥2 series) | false hides the colour legend. |
Bars expect a category x; number / date x suit line / area.
Theming
Section titled “Theming”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.
Accessibility
Section titled “Accessibility”- 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>isaria-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.
Progressive enhancement
Section titled “Progressive enhancement”- No JavaScript → the
<table class="hc-table">renders as a normal, readable table. - JavaScript, no Plot → same:
installChartis a no-op without Plot. - JavaScript + Plot → the table moves into the accessibility tree and the SVG chart is shown.
Server-side rendering
Section titled “Server-side rendering”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.
This recipe ships the client-side path; the SSR path is an alternative.
Related
Section titled “Related”- Table — the semantic table the chart reads from.
- Data region — refresh a region (and its chart) in response to events.