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.series — and sensible defaults
for the rest. Unknown chart types, missing series and non-numeric values are rejected with
structured errors — nothing fails silently.
| Field | Required | What it is |
|---|---|---|
type | yes | One of the nine 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[] } rows |
title, source | no | Chrome above and below the chart |
options.variant | no | "light" · "dark" · "auto" — see Themes and variants below |
options.target | no | A dashed reference line across cartesian types |
options.format | no | Value formatting: "percent" or "currency:XXX" |
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.
The nine types
| Type | Data it reads | Live examples |
|---|---|---|
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 |
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.
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 visitor's OS color scheme.
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). Note the honest boundary: "auto"
reads the OS preference, not any theme toggle a host page might implement — a page with its
own toggle should resolve the variant itself and pass "light" or "dark" explicitly, which is
exactly what this site does.
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" }
} Number formats
options.format runs through every surface at once — axis ticks, value labels,
stacked totals, the big number, tooltips. "percent" adds the sign and turns off thousands
shortening (70 means 70%, never 70k%); "currency:XXX" prefixes $, € or £ for USD, EUR and
GBP and falls back to the code itself for anything else, keeping the k-shortening. 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" } // 70 -> 70%
"options": { "format": "currency:USD" } // 48200 -> $48.2k 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"
}]
}