SVG charting without a library
Published artifacts block every external host, so D3, Recharts, Chart.js and Plotly are simply not available. Inlining a full library costs hundreds of KB to use a fraction of it.
The good news: the parts of a charting library you actually need are small. Scales, ticks, paths,
and stacking are a few dozen lines each. This skill ships them, tested, in
scripts/chartkit.js (~8KB, no dependencies, works as ESM, CJS, or a plain <script>).
node scripts/chartkit.test.js # 47 assertions
The five things you need
1. Scales — map data to pixels
const x = chartkit.scaleBand(months, [padLeft, width - padRight], 0.2);
const y = chartkit.scaleLinear([0, maxValue], [height - padBottom, padTop]);
Note the inverted y range. SVG's origin is top-left and y grows downward, so a value scale runs
from height down to 0. Getting this backwards produces an upside-down chart that otherwise looks
plausible — check it early.
scaleLinear pins to the range midpoint on a zero-width domain rather than dividing by zero, so a
flat series renders instead of vanishing into NaN.
2. Nice ticks — round numbers, not raw divisions
const ticks = chartkit.niceTicks(0, 4_820_000, 5);
const [lo, hi] = chartkit.niceDomain(0, 4_820_000, 5); // expand to tick boundaries
Steps snap to 1, 2, 2.5, 5, or 10 × a power of ten. The 2.5 is deliberate: it produces 250 / 500 / 750 on money axes, which reads far better to a finance audience than 200 / 400 / 600.
Ticks are derived from the index rather than accumulated, so you never get 0.30000000000000004
in an axis label.
3. Paths
chartkit.linePath(points) // breaks at nulls - a gap is a gap
chartkit.linePath(points, {connectNulls: true})
chartkit.areaPath(points, baselineY)
chartkit.barPath(x, y, w, h, radius) // handles negative heights
linePath breaks the line at a null by default, and that default is load-bearing. Connecting
across a missing month draws a straight line through data that does not exist. Only pass
connectNulls when you can defend it.
4. Stacking
const cols = chartkit.stack([
{key: 'subscription', values: [...]},
{key: 'services', values: [...]},
]);
const pct = chartkit.stack(series, {normalize: true}); // 100% stacked
Negative values stack downward from zero rather than folding into the positive stack. A contra item folded into the positive stack silently overstates the total.
5. Waterfall layout
const {bars, domain, footing} = chartkit.waterfallLayout([
{label: 'Opening ARR', value: 4_610_000, type: 'anchor'},
{label: 'New', value: 180_000},
{label: 'Expansion', value: 95_000},
{label: 'Contraction', value: -38_000},
{label: 'Churn', value: -27_000},
{label: 'Closing ARR', value: 4_820_000, type: 'anchor'},
]);
if (footing && !footing.ok) {
throw new Error(`Bridge does not foot: ${footing.variance}`);
}
Check footing.ok and fail loudly. A bridge whose bars do not sum to its closing anchor is a
wrong analysis, and the correct response is to fix the analysis — never to plug the chart so it
looks right. This is the single most important line in the whole kit.
See bridge-charts for the visual conventions on top of this layout.
Assembling a chart
The shape is always the same. Compute geometry, emit markup.
const W = 720, H = 360, m = {top: 24, right: 16, bottom: 40, left: 64};
const y = chartkit.scaleLinear(chartkit.niceDomain(0, max, 5), [H - m.bottom, m.top]);
const x = chartkit.scaleBand(labels, [m.left, W - m.right], 0.25);
const svg = `
<svg viewBox="0 0 ${W} ${H}" role="img" aria-labelledby="t desc"
preserveAspectRatio="xMidYMid meet" style="width:100%;height:auto">
<title id="t">Net new ARR by month</title>
<desc id="desc">Bar chart; peak 210K in March.</desc>
${chartkit.niceTicks(...).map(v => `
<line x1="${m.left}" x2="${W - m.right}" y1="${y(v)}" y2="${y(v)}"
class="grid"/>
<text x="${m.left - 8}" y="${y(v)}" class="tick" text-anchor="end"
dominant-baseline="middle">${chartkit.formatCompact(v, {currency: '$', unit: 'K'})}</text>
`).join('')}
${data.map(d => `
<path d="${chartkit.barPath(x(d.label), y(d.value), x.bandwidth(), y(0) - y(d.value), 2)}"
class="bar"/>
`).join('')}
</svg>`;
Rules that matter here:
viewBox+width:100%; height:autois the whole responsive story. Do not set pixel width.- Pin the axis unit (
unit: 'K'). Mixing$1.2Mand$900Kon one axis makes the reader do unit arithmetic. - Escape any text that came from data with
chartkit.esc(). A customer name containing&breaks the SVG otherwise. role="img"with<title>/<desc>is the minimum for screen readers — seeartifact-accessibility.
Labels that collide
Dense bridges and end-of-line series labels overlap. Nudge them apart while preserving order:
const ys = chartkit.avoidOverlap(rawYs, 12, [m.top, H - m.bottom]);
Order preservation matters: labels that cross swap their apparent series, which is worse than overlapping.
SVG or canvas
| Element count | Use |
|---|---|
| Under ~1,000 nodes | SVG — styleable, accessible, inspectable, printable |
| 1,000-5,000 | SVG, but drop per-element listeners; hit-test from data instead |
| Over ~5,000 | Canvas for the marks, SVG overlay for axes and interaction |
Almost every finance chart is under 1,000 nodes. Reach for canvas only when a scatter or a tick
chart genuinely demands it. See artifact-performance.
Things that bite
| Symptom | Cause |
|---|---|
| Chart upside down | Forgot the inverted y range |
| Labels clipped at the edges | Margins not reserved for the widest tick label |
| Blurry lines | Half-pixel coordinates; align strokes to .5 offsets |
| Text unreadable when printed | Colour-only encoding, no greyscale fallback |
| Chart vanishes at narrow widths | Fixed width attribute instead of viewBox |
NaN in a path |
A null in the data reached the scale — filter first |
| Bars overlap | Band padding of 0, or bandwidth computed from the wrong range |
Related skills
artifact-architecture— why no library loads in the first placebridge-charts,variance-charts,timeseries-finance— what to build with these primitivesartifact-performance— when to leave SVGartifact-accessibility— making the output readable by everyonemermaid-in-artifacts— the case where you should not hand-roll anything