Charts

layout: chart reads all of its children, fixes one shared scale from their data, then draws — bars, lines, areas, dots and bubbles on one plane, and the part-to-whole layout: pie.

Categories and one series

|chart| "Deploys per weekday" { categories: "Mon", "Tue", "Wed", "Thu", "Fri"; } [
  |bars| { data: 4, 9, 6, 12, 7; }
]
Mon: 4 Tue: 9 Wed: 6 Thu: 12 Fri: 7 0 5 10 15 Mon Tue Wed Thu Fri Deploys per weekday Mon: 4 Tue: 9 Wed: 6 Thu: 12 Fri: 7

categories: names the x axis and data: gives one value per category — the two counts must match, and a mismatch is an error rather than a short bar.

Nothing here says how anything should look. The chart’s label became the title, the value axis fitted itself to the numbers, and the colour came off the palette.

More than one series

|chart| "Revenue by region ($k)" {
  categories: "Q1", "Q2", "Q3", "Q4"; bars: stacked;
} [
  |bars| "NA" { data: 40, 45, 50, 60; }
  |bars| "EU" { data: 30, 35, 33, 40; }
  |bars| "APAC" { data: 20, 25, 30, 38; }
]
Q1 · NA: 40 Q1 · EU: 30 Q1 · APAC: 20 Q2 · NA: 45 Q2 · EU: 35 Q2 · APAC: 25 Q3 · NA: 50 Q3 · EU: 33 Q3 · APAC: 30 Q4 · NA: 60 Q4 · EU: 40 Q4 · APAC: 38 0 50 100 150 Q1 Q2 Q3 Q4 Revenue by region ($k) NA EU APAC Q1 · NA: 40 Q1 · EU: 30 Q1 · APAC: 20 Q2 · NA: 45 Q2 · EU: 35 Q2 · APAC: 25 Q3 · NA: 50 Q3 · EU: 33 Q3 · APAC: 30 Q4 · NA: 60 Q4 · EU: 40 Q4 · APAC: 38

Every series is a child node, and its label is its legend entry — the legend appears on its own the moment there are two.

bars: on the chart decides how the bar series combine:

  • grouped (the default) — side by side.
  • stacked — piled into a total, as here.
  • overlay — drawn over one another.

Series walk the palette in order, so three series are three distinct hues with no colour declared.

Lines, axes and a reference mark

|chart| "Latency (ms)" { categories: "p50", "p90", "p99"; } [
  |axis#ms| { side: left; }
  |mark| "budget" { at: 250; axis: ms; stroke: --red-deep; stroke-style: dashed; }
  |line| "before" { data: 40, 180, 420; curve: smooth; marker: circle; }
  |line| "after" { data: 30, 120, 260; curve: smooth; marker: circle; }
]
before: p50: 40 before: p90: 180 before: p99: 420 after: p50: 30 after: p90: 120 after: p99: 260 budget 0 100 200 300 400 500 p50 p90 p99 Latency (ms) before after before: p50: 40 before: p90: 180 before: p99: 420 after: p50: 30 after: p90: 120 after: p99: 260

|line| draws one shape through its data. curve: smooth interpolates without overshooting any point, and marker: puts a mark on every datum.

Write an |axis| only when you have something to say about it — here, only to carry an id so the |mark| can anchor to it.

A mark is placed in data space, not pixels. It tracks the scale, and survives a direction: row flip unchanged.

A formula, and a band across it

|chart| "Edge requests per second" { samples: 96; } [
  |axis#rps| { side: left; range: 0 900; }
  |axis#hour| "Hour of day (UTC)" { side: bottom; range: 0 24; step: 4; }
  |area| { fn: (70 + 620 * exp(-((x - 10.5)^2) / 7) + 470 * exp(-((x - 19)^2) / 11)); }
  |band| "over ceiling" { range: 9.5 11.5; axis: hour; fill: --amber; }
  |mark| "autoscale ceiling" { at: 600; axis: rps; stroke: --red-deep; stroke-style: dashed; }
]
autoscale ceiling 0 200 400 600 800 0 4 8 12 16 20 24 Hour of day (UTC) over ceiling Edge requests per second

A series may compute its values instead of listing them. fn: is an expression in x, sampled samples: times across the domain, with the whole expression engine available — locals, the ternary, and any function the stylesheet binds. Two Gaussians make the morning and evening peaks here.

range: on an axis is the visible window, and it is doing two different jobs above:

  • On the x axis it is the formula’s domain — what fn: sweeps.
  • On the value axis it leaves headroom above the mark.

Points, labels and hover

|chart| "Score vs. thinking budget" { width: 460; height: 280; } [
  |axis| "tokens per task (k)" { side: bottom; }
  |axis| "eval score (%)" { side: left; range: 40 90; }
  |line| "GLM-5.2" { data: 9 52, 16 63, 27 72, 48 79, 88 83; labels: "off", "low", "mid", "high", "max"; curve: smooth; marker: circle; tooltip: always; }
  |dots| "GLM-5.1" { data: 5 47, 12 54, 20 58, 29 62, 40 65, 54 68, 68 69, 86 71; }
]
GLM-5.2: 9, 52 GLM-5.2: 16, 63 GLM-5.2: 27, 72 GLM-5.2: 48, 79 GLM-5.2: 88, 83 GLM-5.1: 5, 47 GLM-5.1: 12, 54 GLM-5.1: 20, 58 GLM-5.1: 29, 62 GLM-5.1: 40, 65 GLM-5.1: 54, 68 GLM-5.1: 68, 69 GLM-5.1: 86, 71 40 50 60 70 80 90 eval score (%) 20 40 60 80 tokens per task (k) Score vs. thinking budget GLM-5.2 GLM-5.1 off low mid high max GLM-5.2: 9, 52 GLM-5.2: 16, 63 GLM-5.2: 27, 72 GLM-5.2: 48, 79 GLM-5.2: 88, 83 GLM-5.1: 5, 47 GLM-5.1: 12, 54 GLM-5.1: 20, 58 GLM-5.1: 29, 62 GLM-5.1: 40, 65 GLM-5.1: 54, 68 GLM-5.1: 68, 69 GLM-5.1: 86, 71

x y pairs make a numeric x axis — a scatter, rather than the categories of the first two figures.

labels: is the per-datum text, one entry per point. Where a label fits it sits on the plot; where it does not it falls to a hover card, and tooltip: always forces every one inline for export. Every marked point also carries a native <title>, so the figure is readable by hover in any viewer.

Left to fit itself, the value axis would start at zero and crush these scores into the top strip. range: 40 90 is the window that actually matters.

Dates and calendar ticks

|chart| "p95 latency, eu-west (ms)" { width: 560; } [
  |axis#t| { side: bottom; step: 2 week; }
  |band| "incident" { range: "2026-01-24" "2026-02-14"; axis: t; fill: --amber; }
  |mark| "rollback" { at: "2026-02-09"; axis: t; stroke: --red-deep; stroke-style: dashed; }
  |line| { data: "2026-01-05" 240, "2026-01-12" 232, "2026-01-19" 251, "2026-01-26" 384, "2026-02-02" 412, "2026-02-09" 301, "2026-02-16" 262, "2026-02-23" 244, "2026-03-02" 231, "2026-03-09" 225, "2026-03-16" 219; curve: smooth; marker: dot; }
]
Jan 5 2026, 240 Jan 12 2026, 232 Jan 19 2026, 251 Jan 26 2026, 384 Feb 2 2026, 412 Feb 9 2026, 301 Feb 16 2026, 262 Feb 23 2026, 244 Mar 2 2026, 231 Mar 9 2026, 225 Mar 16 2026, 219 rollback 0 100 200 300 400 500 Jan 5 Jan 19 Feb 2 Feb 16 Mar 2 Mar 16 incident p95 latency, eu-west (ms) Jan 5 2026, 240 Jan 12 2026, 232 Jan 19 2026, 251 Jan 26 2026, 384 Feb 2 2026, 412 Feb 9 2026, 301 Feb 16 2026, 262 Feb 23 2026, 244 Mar 2 2026, 231 Mar 9 2026, 225 Mar 16 2026, 219

A point’s x may be a quoted ISO date, and the dates are what make the axis a time axis — there is no scale: to write.

Ticks are then calendar-aware: the span picks the unit, the ticks land on real boundaries, and the label follows that unit. step: overrides it with a calendar interval — 2 week here, or month — and a plain number is an error.

Everything else measured on that axis is written in the same literals. The |band| shading the incident and the |mark| on the rollback both take dates, because a dated domain is a domain like any other — the other kind is an error either way round. All the arithmetic is UTC, so the figure renders the same in every timezone.

Bubbles, and a third number

|chart| "Cost by service ($k / month)" { width: 460; height: 300; } [
  |axis| "requests/s" { side: bottom; }
  |axis| "p99 latency (ms)" { side: left; }
  |bubble| "billing" { at: 300 60; value: 4; }
  |bubble| "auth" { at: 1200 45; value: 7; }
  |bubble| "cart" { at: 900 105; value: 9; }
  |bubble| "media" { at: 2100 155; value: 40; }
  |bubble| "search" { at: 3400 215; value: 26; }
  |bubble| "feed" { at: 4200 95; value: 17; }
]
billing: 4 auth: 7 cart: 9 media: 40 search: 26 feed: 17 0 50 100 150 200 250 p99 latency (ms) 0 1000 2000 3000 4000 requests/s Cost by service ($k / month) billing auth cart media search feed billing: 4 auth: 7 cart: 9 media: 40 search: 26 feed: 17

A |bubble| is one mark per node rather than a series of data: at: places it on the plane, and value: sizes it.

The scale is by area, so four times the cost is twice the radius. That is what lets a bubble carry a third number without a third axis.

The label rides inside the bubble where it fits and beside it where it does not — and each bubble, being its own node, takes the next hue in the palette walk.

Radar

|chart| "Profiles" { direction: radial; categories: "Speed", "Range", "Armour", "Cost", "Stealth"; } [
  |axis| { range: 0 5; }
  |line| "Scout" { data: 5, 4, 2, 3, 5; }
  |area| "Cruiser" { data: 3, 3, 5, 4, 2; fill: --purple; }
]
Speed Range Armour Cost Stealth 1 2 3 4 5 Profiles Scout Cruiser

direction: radial bends the category axis into a ring — one spoke per category — and turns the value axis into the radius. A |line| then closes into a polygon, and an |area| fills one.

The data is untouched by that flip, which is the rule for every direction: the plane is projected differently, never re-authored.

Part to whole

|pie| "Spend by channel" { hole: 0.5; } [
  |slice| "Ads" { value: 40; }
  |slice| "SEO" { value: 25; }
  |slice| "Email" { value: 20; }
  |slice| "Direct" { value: 15; }
]
Ads: 40 (40%) SEO: 25 (25%) Email: 20 (20%) Direct: 15 (15%) Spend by channel Ads SEO Email Direct Ads: 40 (40%) SEO: 25 (25%) Email: 20 (20%) Direct: 15 (15%)

layout: pie turns each |slice|’s value: into an angle; the values need no normalising, since the whole is their sum. hole: is the inner-radius fraction, so 0.5 is a donut and the default 0 a full pie.

Go deeper

The full chart reference covers log and reversed scales, per-datum paint, segmented formulas, format: and the tooltip rules.