Docs · Chart spec
One JSON object in, a living chart out
Everything the engine draws is described by one spec. This page is the full reference — the same contract whether the spec arrives through a maker, an embed, an MCP tool call, or the render API.
{
"type": "column",
"theme": "editorial",
"title": "Weekly signups",
"source": "Product analytics",
"data": {
"labels": [
"Mon",
"Tue",
"Wed",
"Thu",
"Fri"
],
"series": [
{
"name": "Signups",
"values": [
12,
19,
14,
27,
22
]
}
]
}
} That chart is this exact spec, rendered live.
The shape
Two required fields — type and data — and sensible defaults for the rest. Most
types use labels and series. The status matrix takes data.columns
and data.cells, the live table data.columns and data.rows;
hierarchy, rank, stream, distribution and flow types use the formats described below. Unknown
chart types, missing required data and non-numeric values are rejected with structured errors — nothing fails
silently. For labels-and-series data, every series carries exactly one value per label: a series with the wrong count is refused,
never padded with zeros or trimmed. A value written as null is a gap — a point with no data —
drawn as a break in a line, no bar or dot, "No data" in the tooltip and the hidden table (not allowed on
number and dotplot).
| Field | Required | What it is |
|---|---|---|
type | yes | One of the twenty-five types below |
theme | no (defaults to editorial) | editorial · glass · ink · mint · neon (unknown names fall back to editorial) |
data.labels | no (defaults to 1, 2, 3…) | Category names, one per position |
data.series | yes | One or more { "name"?, "values": (number | null)[] } rows — one
value per label; null is a gap |
title, source | no | Chrome above and below the chart |
options.variant | no | "light" · "dark" · "auto" — "auto" follows the host page's own dark-mode toggle (a
data-theme or a dark class on html/body, or color-scheme), then the OS, and
switches live; see Themes and variants below |
options.target | no | A dashed reference line across cartesian types |
options.precision | no | Decimals (0–4) for value labels, the tooltip and the big number; omitted = the decimals in the data |
options.format | no | Value formatting: "percent", "currency:XXX" or affix:["before","after"] |
options.notes | no | Notes: { "label", "text", "kind"? } per noted label — a ring (ⓘ;
! for "warn") on bar, column, lollipop, line, area, scatter, the donut legend and the big number; hover or tap the ring for the full text,
click to keep it, Escape to release; the text also sits in the accessible data table |
options.legend | no | Matrix only: human labels for the status vocabulary |
data.columns, data.cells | matrix only | Feature columns and one status row per label |
data.columns, data.rows | table only | Typed columns (text · number · status ·
note) and one array of cells per row |
options.sort, options.sticky | no (table only) | Initial order { column, dir
}; pinned header and first column |
options.mobile | no (table only) | Under 480 px of container width: "scroll" (default — a compact table that scrolls sideways under a sticky first column) or "cards" (one card per row) |
options.font | no (table only) | "inherit" sets the table in the host page's own font instead of
the theme's — for a table dropped into an article. A table also takes its own height unless the page gives it one. |
options.height, options.search | no (table only) | height (px, 120–4000) caps a
long table: the rows scroll inside it under the sticky header. search: true adds a box above the rows that narrows them by any
text they show — label, cells, status labels, notes — together with the status chips; Reset clears both. |
options.pageSize | no (table only) | Rows per page, 5–1000: the table shows that many at a time with Previous / Next under the rows, an honest "Rows 11–20 of 63" line and a one-click Show all. Chips, search and sort narrow and order the rows first, then the pages follow; "Chart this column" draws the rows on view across every page. A table with no more rows than the page size shows no pager. |
Negative values are rejected for every type except number — bars and areas measured from a
zero baseline cannot show them honestly, and a structured error beats a misleading picture. A negative target is rejected everywhere.
Sankey flows
data.links contains source, target and value. Nodes are inferred from the links.
Optional data.nodes supplies IDs, labels and input order. Weights are positive or null;
duplicate links sum. Cycles and self-links return errors with row paths.
{
"type": "sankey",
"data": {
"links": [
{
"source": "Income",
"target": "Budget",
"value": 120
},
{
"source": "Budget",
"target": "Spending",
"value": 100
}
]
}
} Node height uses the larger of inflow and outflow. The tooltip reports any difference without inventing links. Use up to 50 nodes, 100 links and eight stages. Group small flows into Other when you reach the cap.
options.flowColor: source (default) or stage. options.flowOrder: minimize (default)
or input. options.showValues: true by default. Names remain when values are hidden.
The diagram scrolls inside its box on narrow screens, with a collapsed Links table below.
Open Sankey maker.
Ridgeline: grouped observations
Use data.groups with id, label and
observations for each group. Values may be negative; null means missing. Keep up to 12
groups and 2,000 observations locally. Hosted rendering retains its plan's existing point cap.
{
"type": "ridgeline",
"data": {
"groups": [
{
"id": "monday",
"label": "Monday",
"observations": [
12,
15,
16,
19,
24
]
}
]
},
"options": {
"bandwidth": 2
}
}
Gaussian kernel density estimates use one Silverman bandwidth from within-group deviations by default. Set a positive options.bandwidth to override it. The shared X range is the data minimum and maximum plus
four bandwidths on each side. Fewer than five known observations, or constant readings, show dots.
Empty groups say No data. Null observations stay in the table and are excluded from density.
Heights share a density scale. Set options.ridgeScale: "own" to scale each group to its own
peak; the chart prints a notice. options.sortByMedian: true sorts groups by median, with
empty groups last. Hover, pin and arrow keys expose n, median and the middle 50%.
Open Ridgeline maker.
Streamgraph: layers of a changing flow
Use ordinary period labels and series, as for Line or Stacked columns. Streamgraph accepts 1–6 layers and 2–100 periods. Every value must be finite and non-negative. Null is an error; use Line to show gaps.
The silhouette baseline is minus half of each period’s total. Layers accumulate in input order. Smooth
curves interpolate non-negative layer thicknesses and keep every seam shared; values stay exact at each
period. Set options.curve: "linear" for straight boundaries. There is no numeric Y axis.
Hover or use arrow keys for exact values and period shares. Layer names appear where they fit, with a
legend as fallback.
Legend switches are disabled because hidden layers would leave misleading gaps. Value edits tween bands; switching to Line or Stacked columns crossfades. Open Streamgraph maker .
Bump: ranks across periods
Use data.labels for periods and one named series per entity. Set options.rankMode explicitly: values computes competition ranks (1, 2, 2, 4); ranks accepts positive integer positions as supplied. First place is at the top. A null
observation breaks the line.
In Values mode, rankDirection: "descending" ranks larger values first (default); "ascending" ranks smaller values first. showRanks defaults to false; set it to
true to show point ranks. Optional topN (1–12) limits visible positions without inventing
ranks when an entity leaves or returns. Values must be finite and non-negative. Use 1–12 uniquely named
entities and 3–24 uniquely named periods, with names up to 120 characters. Errors identify the field and
correction.
{
"type": "bump",
"data": {
"labels": [
"Jan",
"Feb",
"Mar"
],
"series": [
{
"name": "Atlas",
"values": [
40,
30,
45
]
},
{
"name": "Birch",
"values": [
35,
42,
45
]
}
]
},
"options": {
"rankMode": "values",
"rankDirection": "descending"
}
} Hover a line to highlight its entity. Left and right move across periods; up and down move through places in a period. Click or Enter pins a point; Shift adds a comparison. The accessible table retains every period, rank and source value. PNG, SVG, render-api and live embeds use the same scene. Choose Bump chart maker to edit the data. Line shows value gaps; Slope suits two periods.
Treemap and Sunburst: groups and items
Treemap and Sunburst read data.nodes. Groups have an ID and label; items name their group
with parentId and supply a positive finite value or null for No data. Group
totals are calculated from the items. Use up to 6 groups and 36 items in two levels. IDs must be unique.
Missing parents, cycles, deeper nesting, empty groups, zero and negative values return an error with the
node path and a correction.
{
"type": "treemap",
"title": "City budget",
"data": {
"nodes": [
{
"id": "spaces",
"label": "Public spaces"
},
{
"id": "parks",
"parentId": "spaces",
"label": "Parks",
"value": 32
},
{
"id": "libraries",
"parentId": "spaces",
"label": "Libraries",
"value": 18
}
]
},
"options": {
"showValues": true,
"precision": 0,
"variant": "auto"
}
}
Open a group with click, tap or Enter. All, Escape or Backspace returns to the whole chart. This view
state is not saved in the spec. Missing and subpixel items remain in the data table and keyboard
tooltips. Use options.showValues: false to hide tile values; names stay visible when they
fit. Format, precision, theme and variant follow the same rules as other charts. Sunburst uses the same
nodes: change type to sunburst. Its inner ring shows groups and its outer ring
shows items; the center shows their total. Labels appear only when they fit. Edit a treemap or make a sunburst.
The twenty-five types
| Type | Data it reads | Live examples |
|---|---|---|
sankey | Directed links: source, target, positive value or null; 50 nodes, 100 links, 8 stages | see it live |
ridgeline | 1–12 groups of raw observations; 2,000 readings total | see it live |
streamgraph | 1–6 layers, 2–100 periods; non-negative values with no gaps | see it live |
column | labels + 1..n series | see it live |
bar | labels + 1..n series, horizontal | see it live |
line | labels + 1..n series | see it live |
area | labels + 1..n series, filled to zero | see it live |
stacked | labels + 1..n series, summed per label | see it live |
donut | labels + the FIRST series only | see it live |
scatter | labels + 1..n series, one dot per value | see it live |
dotplot | labels + counts, one drawn dot each (fractional counts are floored) | see it live |
number | the first value of the first series; signed | see it live |
lollipop | labels + 1..3 series — stems and dots; 2-3 series drop the stems (Cleveland dots) | see it live |
dumbbell | labels + exactly 2 series — two dots per row joined by a link, the change labeled | see it live |
range | labels + exactly 2 series (low, high; high ≥ low) — one band per row | see it live |
waterfall | labels + 1 series of steps ≥ 0 — floating columns from the running total; options.total closes it | see it live |
bullet | labels + actuals (+ per-row targets as series 2) over options.bands | see it live |
slope | exactly 2 labels (then, now) + 1..n series — one straight line per series, named at both ends | see it live |
heatmap | labels (columns) + series (rows) — every cell shaded by its value; options.scale pins the range | see it live |
waffle | labels + the FIRST series only — parts of a whole as options.units squares (default 100) | see it live |
matrix | labels (rows) + columns + cells of yes | no | partial | unknown | na | see it live |
sunburst | the same two-level nodes as treemap, drawn as rings | see it live |
bump | 3–24 periods, 1–12 named entities; explicit values or ranks mode | see it live |
treemap | two levels of nodes: groups and positive-valued items (or null) | see it live |
table | typed columns + rows of text, numbers, statuses and notes — rendered as a live DOM table, not a canvas | see it live |
Changing type on a mounted chart does not redraw it — the engine morphs the existing shapes
into the new ones. That is the whole point of the project.
For column with one series, options.histogram: true draws square bins in one
palette color, touching with a 1px gap; omit it for ordinary columns.
{ "type": "column", "options": { "histogram": true },
"data": { "labels": ["0–10", "10–20", "20–30"], "series": [{ "values": [9, 27, 38] }] } } Themes and variants
Five themes, each in a light and a dark variant — ten looks in total. options.variant
picks one: "light" and "dark" pin it, and "auto" follows the host page first — a
data-theme attribute or a dark class on html or body,
or its color-scheme — then the visitor's OS, and switches live when either
changes. Omitting the field does neither — each theme then shows its own canonical side
(editorial, glass and mint are light by nature; ink and neon are dark). A page that resolves
its theme itself can still pass "light" or "dark" explicitly.
Annotations
Honesty as a feature: options.notes pins a caveat to a label — a cut-off date, a
known-part-only total, an unverified claim. Each noted label grows a small ring on bar, column, lollipop,
line, area, donut, scatter and number charts (an i
glyph; ! for "warn" — a "partial" note keeps the warn tint); the matrix marks the row's cells
with a corner dot. Hover or tap the ring to read the full text, click to keep it open; it also sits in the
hidden accessible table, so the fine print travels with the chart instead of dying in a footnote.
"options": {
"notes": [
{ "label": "Vanilla", "text": "Updates stop from 1 January 2025", "kind": "warn" },
{ "label": "Circle", "text": "Known part only; processing is extra", "kind": "partial" }
]
} The status matrix
matrix reads cells instead of series: rows come from
data.labels, features from data.columns, and every cell is a status from the
fixed vocabulary yes | no | partial | unknown | na — a string, or { "status", "note" } to attach fine print to one cell. Statuses draw as shapes
(check, cross, half-disc, question mark, dash), never as color alone, and
options.legend renames them for the legend, tooltip and accessible table. Unknown and
not-applicable are distinct states on purpose — the chart never dresses a missing fact as a no.
{
"type": "matrix",
"data": {
"labels": ["Discourse", "Flarum"],
"columns": ["Licence fee", "Server", "Updates"],
"cells": [
["yes", "no", "yes"],
["yes", "no", { "status": "partial", "note": "Community builds only" }]
]
},
"options": { "legend": { "yes": "None to pay / included", "no": "On the team / paid" } }
} The live table
table is the one type that is not a picture: <live-chart> mounts a real
HTML table (loaded as a second small file only when a table is on the page). Columns carry a kind — text, number (with the usual format),
status (the matrix vocabulary, drawn as a chip with a shape and an optional label) or note — and each cell is a string, a number, null,
{ "status", "label", "note" } or { "text", "kind", "note" }
(note is optional). The table sorts on a header click, filters by status chips, draws a mini-bar behind
every number, expands notes on click, and scrolls sideways below 480 px of width. Set options.mobile: "cards" to use cards instead. Its "Chart" button hands a numeric column — or
a status column whose chips carry numbers — to the engine as a lollipop. There is no SVG export (a
structured refusal says so); PNG renders through the local MCP (Pro).
{
"type": "table",
"title": "What you are buying",
"data": {
"columns": [
{ "name": "Product" },
{ "name": "Licence cost", "kind": "status" },
{ "name": "Monthly", "kind": "number", "format": "currency:USD" },
{ "name": "What still lands on the team", "kind": "note" }
],
"rows": [
["Discourse", { "status": "yes", "label": "None" }, 100, "Server and an SMTP mail service"],
["Vanilla", "yes", null, { "text": "Updates stop from 1 January 2025", "kind": "warn" }]
]
},
"options": { "sort": { "column": 2, "dir": "desc" }, "sticky": true }
}
In a text or note column, { "text": "Limited claim", "kind": "warn", "note": "Full explanation" }
keeps the claim visible and opens the explanation with the ! button. Use note, warn or partial for kind. The explanation is plain text, up to 4000 Unicode
characters; noteText and detail return validation errors. Without note, text
remains visible and the glyph explains its meaning. A status cell's existing + note still works. Paste the
complete table JSON into the table maker to preserve typed cells and layout;
Download JSON saves them without the share-link size limit.
Target lines
A goal, a limit, an SLA: options.target draws a dashed line at a value across column, bar,
line, area, stacked, scatter and dotplot charts, with an optional label. The scale always stretches to
include it.
"options": {
"target": { "value": 120, "label": "Goal" }
} Lines and parts
options.curve shapes a line or area between its points: "smooth" (the default),
"linear" for straight segments, "step" for stairs — every value a plateau centered
on its label, the riser midway between labels (see step charts live). A slope takes exactly
two labels, the two states,
and names every series at both ends with its exact value; a null on either side gaps that line and reads "—".
A heatmap uses the ordinary data shape — labels are the columns, series are the rows — and shades
each cell from the theme background to its first palette color; options.scale { "min", "max" } pins that range (values outside clamp), a null cell stays flat and unlabeled.
A waffle lays the first series out as options.units squares (1–400, default 100) by
largest remainders, so the squares always sum to the units; its legend is the donut's, and hovering any
square lights the whole part.
{ "type": "line", "options": { "curve": "step" } }
{ "type": "slope", "data": { "labels": ["2024", "2025"], "series": [{ "name": "Search", "values": [31, 44] }] } }
{ "type": "heatmap", "options": { "scale": { "min": 0, "max": 50 } },
"data": { "labels": ["Mon", "Tue"], "series": [{ "name": "North", "values": [12, 30] }, { "name": "South", "values": [8, null] }] } }
{ "type": "waffle", "options": { "units": 100 }, "data": { "labels": ["Yes", "No"], "series": [{ "values": [73, 27] }] } } Number formats
options.format runs through every surface at once — axis ticks, value labels, stacked totals,
the big number, tooltips. "percent" adds the sign; "currency:XXX" prefixes $, € or £ for USD, EUR and GBP
and falls back to the code itself for anything else (CAD 1,234.5). A unit of your own is
affix:["before","after"]: affix:[""," lb"] reads 1,234.5 lb,
affix:["~",""] reads ~1,234.5 — up to 12 characters a side, a space included,
and the values are never converted. Numbers a reader reads are exact: value labels,
tooltips, the big number and table cells keep the decimals the data carries (99.98% reads 99.98%, $201.5
reads $201.50), group thousands (12,345) and never shorten; only axis ticks stay compact ($48k). options.precision
(0–4) sets the decimals by hand. Two deliberate exceptions stay plain: a donut slice's share (already a
percentage) and a dot plot's counts (neither money nor a share).
"options": { "format": "percent" } // 99.98 -> 99.98% on the bar, 100% on the axis
"options": { "format": "currency:USD" } // 48200 -> $48,200 on the bar, $48k on the axis
"options": { "precision": 2 } // 5 -> 5.00 everywhere a reader reads it Errors are structured
Parsing never throws and never guesses. A bad spec comes back as a list of
{path, message, hint} objects pointing at the exact field — the same shape in the
browser, over MCP and from the render API, designed to be read by people and agents alike.
{
"ok": false,
"errors": [{
"path": "data.series[0].values[2]",
"message": "Value \"abc\" is not a finite number",
"hint": "Use numeric values, e.g. 42 or 3.14"
}]
}