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.
Also known as: graphs, data visualization.
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, 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).
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>Horizontal bars (bar-x)
Section titled “Horizontal bars (bar-x)”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.
| Product | Sales |
|---|---|
| Wireless headphones | 320 |
| Mechanical keyboard | 260 |
| USB-C dock | 210 |
| Laptop stand | 150 |
| Webcam | 90 |
<figure class="hc-chart" data-hc-chart="bar-x" data-tip data-y-label="Sales ($k)"> <table class="hc-table"> <caption>Top products</caption> <thead> <tr><th>Product</th><th>Sales</th></tr> </thead> <tbody> <tr><td>Wireless headphones</td><td>320</td></tr> <tr><td>Mechanical keyboard</td><td>260</td></tr> <tr><td>USB-C dock</td><td>210</td></tr> <tr><td>Laptop stand</td><td>150</td></tr> <tr><td>Webcam</td><td>90</td></tr> </tbody> </table></figure>Horizontal grouped bars (bar-x-grouped)
Section titled “Horizontal grouped bars (bar-x-grouped)”Series render side-by-side within each category, horizontally (faceted on the row axis).
| Quarter | Store | Online |
|---|---|---|
| Q1 | 80 | 45 |
| Q2 | 95 | 70 |
| Q3 | 60 | 98 |
<figure class="hc-chart" data-hc-chart="bar-x-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>Waterfall (waterfall)
Section titled “Waterfall (waterfall)”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).
| Step | Amount |
|---|---|
| Opening | 100 |
| Sales | +80 |
| Refunds | -15 |
| Costs | -30 |
| Closing | 135 |
<figure class="hc-chart" data-hc-chart="waterfall" data-tip data-y-label="Cash ($k)"> <table class="hc-table"> <caption>Cash bridge</caption> <thead> <tr><th>Step</th><th>Amount</th></tr> </thead> <tbody> <tr data-total><td>Opening</td><td>100</td></tr> <tr><td>Sales</td><td>+80</td></tr> <tr><td>Refunds</td><td>-15</td></tr> <tr><td>Costs</td><td>-30</td></tr> <tr data-total><td>Closing</td><td>135</td></tr> </tbody> </table></figure>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.
Tooltips (data-tip)
Section titled “Tooltips (data-tip)”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:
| 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-tip 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>Tooltips are client-side interactivity — they do not apply to the server-rendered path.
Click-through (data-link)
Section titled “Click-through (data-link)”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.
<figure class="hc-chart" data-hc-chart="bar" data-link data-tip data-y-label="Sales ($k)"> <table class="hc-table"> <caption>Sales by product</caption> <thead> <tr><th>Product</th><th>Sales</th></tr> </thead> <tbody> <tr><td><a href="/products/alpha" data-hx-get="/products/alpha/panel" data-hx-target="#detail">Alpha</a></td><td>320</td></tr> <tr><td><a href="/products/beta" data-hx-get="/products/beta/panel" data-hx-target="#detail">Beta</a></td><td>180</td></tr> </tbody> </table></figure>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.
Y axis: domain and format
Section titled “Y axis: domain and format”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").
| Month | Revenue |
|---|---|
| Jan | 4,200 |
| Feb | 4,650 |
| Mar | 4,400 |
| Apr | 5,100 |
| May | 5,600 |
<figure class="hc-chart" data-hc-chart="line" data-tip data-y-min="3000" data-y-max="6000" data-y-format="s" data-y-label="Revenue (¥)"> <table class="hc-table"> <caption>Monthly revenue</caption> <thead> <tr><th>Month</th><th>Revenue</th></tr> </thead> <tbody> <tr><td>Jan</td><td>4,200</td></tr> <tr><td>Feb</td><td>4,650</td></tr> <tr><td>Mar</td><td>4,400</td></tr> <tr><td>Apr</td><td>5,100</td></tr> <tr><td>May</td><td>5,600</td></tr> </tbody> </table></figure>With stacked bars set both bounds explicitly — the fallback extent reads raw cell values and ignores stacking.
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. |
data-tip | off | Hover tooltip; bare, or x | y | xy. |
data-y-min / data-y-max | data extent | Pin the y domain. |
data-y-format | Plot default | y tick format (d3-format string). |
data-link | off | Clicks forward to the row’s first-column anchor. |
Bars expect a category x; number / date x suit line / area.
Everything else: the buildOptions hook
Section titled “Everything else: the buildOptions hook”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.
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.