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.
Live demo
Section titled “Live demo”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.
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.
| Week | Web | Mobile |
|---|---|---|
| W1 | 42 | 18 |
| W2 | 48 | 24 |
| W3 | 39 | 31 |
| W4 | 55 | 36 |
| W5 | 61 | 33 |
<figure class="hc-chart" data-hc-chart="line" data-y-label="Sessions (k)"> <table class="hc-table"> <caption>Weekly sessions</caption> <thead> <tr><th>Week</th><th>Web</th><th>Mobile</th></tr> </thead> <tbody> <tr><td>W1</td><td>42</td><td>18</td></tr> <tr><td>W2</td><td>48</td><td>24</td></tr> <tr><td>W3</td><td>39</td><td>31</td></tr> <tr><td>W4</td><td>55</td><td>36</td></tr> <tr><td>W5</td><td>61</td><td>33</td></tr> </tbody> </table></figure>| Month | Used |
|---|---|
| Jan | 120 |
| Feb | 135 |
| Mar | 170 |
| Apr | 165 |
| May | 210 |
<figure class="hc-chart" data-hc-chart="area" data-y-label="Storage (GB)"> <table class="hc-table"> <caption>Storage used</caption> <thead> <tr><th>Month</th><th>Used</th></tr> </thead> <tbody> <tr><td>Jan</td><td>120</td></tr> <tr><td>Feb</td><td>135</td></tr> <tr><td>Mar</td><td>170</td></tr> <tr><td>Apr</td><td>165</td></tr> <tr><td>May</td><td>210</td></tr> </tbody> </table></figure>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.
Stacked, grouped, scatter, sparkline
Section titled “Stacked, grouped, scatter, sparkline”Four whole-figure presets extend the same table contract (per-column
data-mark combos don’t apply to them).
Stacked bars (bar-stacked)
Section titled “Stacked bars (bar-stacked)”Multiple series stack (the explicit form of what multi-series bar
already does).
| Quarter | Store | Online |
|---|---|---|
| Q1 | 80 | 45 |
| Q2 | 95 | 70 |
| Q3 | 60 | 98 |
<figure class="hc-chart" data-hc-chart="bar-stacked" data-y-label="Orders"> <table class="hc-table"> <caption>Orders by channel</caption> <thead> <tr><th>Quarter</th><th>Store</th><th>Online</th></tr> </thead> <tbody> <tr><td>Q1</td><td>80</td><td>45</td></tr> <tr><td>Q2</td><td>95</td><td>70</td></tr> <tr><td>Q3</td><td>60</td><td>98</td></tr> </tbody> </table></figure>Grouped bars (bar-grouped)
Section titled “Grouped bars (bar-grouped)”Series render side-by-side within each category; the category axis carries the labels and the legend names the series.
| Quarter | Store | Online |
|---|---|---|
| Q1 | 80 | 45 |
| Q2 | 95 | 70 |
| Q3 | 60 | 98 |
<figure class="hc-chart" data-hc-chart="bar-grouped" data-y-label="Orders"> <table class="hc-table"> <caption>Orders by channel</caption> <thead> <tr><th>Quarter</th><th>Store</th><th>Online</th></tr> </thead> <tbody> <tr><td>Q1</td><td>80</td><td>45</td></tr> <tr><td>Q2</td><td>95</td><td>70</td></tr> <tr><td>Q3</td><td>60</td><td>98</td></tr> </tbody> </table></figure>Scatter (scatter)
Section titled “Scatter (scatter)”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 | Weight | Count |
|---|---|---|
| 150 | 52 | 3 |
| 158 | 58 | 6 |
| 165 | 63 | 12 |
| 172 | 70 | 9 |
| 180 | 78 | 4 |
<figure class="hc-chart" data-hc-chart="scatter" data-y-label="Weight (kg)"> <table class="hc-table"> <caption>Height vs weight</caption> <thead> <tr><th>Height</th><th>Weight</th><th data-role="r">Count</th></tr> </thead> <tbody> <tr><td>150</td><td>52</td><td>3</td></tr> <tr><td>158</td><td>58</td><td>6</td></tr> <tr><td>165</td><td>63</td><td>12</td></tr> <tr><td>172</td><td>70</td><td>9</td></tr> <tr><td>180</td><td>78</td><td>4</td></tr> </tbody> </table></figure>Sparkline (sparkline)
Section titled “Sparkline (sparkline)”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.
| Day | Value |
|---|---|
| 1 | 12 |
| 2 | 18 |
| 3 | 9 |
| 4 | 22 |
| 5 | 17 |
| 6 | 28 |
| 7 | 25 |
<figure class="hc-chart" data-hc-chart="sparkline"> <table class="hc-table"> <caption>30-day trend</caption> <thead> <tr><th>Day</th><th>Value</th></tr> </thead> <tbody> <tr><td>1</td><td>12</td></tr> <tr><td>2</td><td>18</td></tr> <tr><td>3</td><td>9</td></tr> <tr><td>4</td><td>22</td></tr> <tr><td>5</td><td>17</td></tr> <tr><td>6</td><td>28</td></tr> <tr><td>7</td><td>25</td></tr> </tbody> </table></figure>Histogram and heatmap
Section titled “Histogram and heatmap”Histogram (histogram)
Section titled “Histogram (histogram)”Bins one numeric column into count bars (data-x-type defaults to
number; extra columns are ignored; data-bins caps the bin count).
| ms |
|---|
| 90 |
| 95 |
| 110 |
| 120 |
| 125 |
| 140 |
| 160 |
| 180 |
| 210 |
| 260 |
| 340 |
| 520 |
<figure class="hc-chart" data-hc-chart="histogram" data-bins="6" data-y-label="Requests"> <table class="hc-table"> <caption>Response times (ms)</caption> <thead> <tr><th>ms</th></tr> </thead> <tbody> <tr><td>90</td></tr> <tr><td>95</td></tr> <tr><td>110</td></tr> <tr><td>120</td></tr> <tr><td>125</td></tr> <tr><td>140</td></tr> <tr><td>160</td></tr> <tr><td>180</td></tr> <tr><td>210</td></tr> <tr><td>260</td></tr> <tr><td>340</td></tr> <tr><td>520</td></tr> </tbody> </table></figure>Heatmap (heatmap)
Section titled “Heatmap (heatmap)”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.
| Slot | Mon | Tue | Wed | Thu | Fri |
|---|---|---|---|---|---|
| Morning | 3 | 7 | 5 | 8 | 4 |
| Afternoon | 6 | 4 | 9 | 5 | 7 |
| Evening | 9 | 2 | 6 | 3 | 10 |
<figure class="hc-chart" data-hc-chart="heatmap" data-y-label="Visits"> <table class="hc-table"> <caption>Visits by slot</caption> <thead> <tr><th>Slot</th><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th></tr> </thead> <tbody> <tr><td>Morning</td><td>3</td><td>7</td><td>5</td><td>8</td><td>4</td></tr> <tr><td>Afternoon</td><td>6</td><td>4</td><td>9</td><td>5</td><td>7</td></tr> <tr><td>Evening</td><td>9</td><td>2</td><td>6</td><td>3</td><td>10</td></tr> </tbody> </table></figure>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 —
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.
Related
Section titled “Related”- Table — the semantic table the chart reads from.
- Data region — refresh a region (and its chart) in response to events.