// Draws each [data-chart] card from its own <table>: "bar", "line" (area) or "donut". No library.
// The table stays in the page for screen readers (visually hidden) and "Show table" puts it back on screen.
(() => {
const HIDE = 'sr-only';
const fmt = {
number: new Intl.NumberFormat('en-US'),
usd: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }),
'usd-compact': new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 })
};
const r1 = n => Math.round(n * 10) / 10;
const esc = s => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
const niceStep = (range, steps) => { const raw = range / steps, mag = 10 ** Math.floor(Math.log10(raw || 1)); return [1, 2, 2.5, 5, 10].map(s => s * mag).find(s => s >= raw); };
const draw = {
bar(rows, w, c, f) {
const h = 200, top = 22, base = h - 26, band = (w - 8) / rows.length, bw = Math.min(38, band * .62);
const max = Math.max(...rows.map(r => r.value));
let s = `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true" focusable="false">`;
rows.forEach((r, i) => {
const x = 4 + band * i + (band - bw) / 2, y = base - (r.value / max) * (base - top), rr = Math.min(4, bw / 3);
s += `<path fill="${c.c1}" d="M${r1(x)} ${base}V${r1(y + rr)}Q${r1(x)} ${r1(y)} ${r1(x + rr)} ${r1(y)}H${r1(x + bw - rr)}Q${r1(x + bw)} ${r1(y)} ${r1(x + bw)} ${r1(y + rr)}V${base}Z"><title>${esc(r.label)}: ${f.format(r.value)}</title></path>`;
s += `<text x="${r1(x + bw / 2)}" y="${r1(y - 6)}" text-anchor="middle" font-size="11" font-weight="600" fill="${c.ink}">${f.format(r.value)}</text>`;
s += `<text x="${r1(x + bw / 2)}" y="${h - 8}" text-anchor="middle" font-size="11" fill="${c.muted}">${esc(r.label)}</text>`;
});
return s + `<line x1="0" x2="${w}" y1="${base}" y2="${base}" stroke="${c.axis}" stroke-width="1"/></svg>`;
},
line(rows, w, c, f) {
const h = 220, top = 18, base = h - 26, left = 46, right = w - 12, vals = rows.map(r => r.value);
const step = niceStep(Math.max(...vals) - Math.min(...vals), 4);
const lo = Math.floor(Math.min(...vals) / step) * step, hi = Math.ceil(Math.max(...vals) / step) * step;
const x = i => left + i * (right - left) / (rows.length - 1), y = v => base - (v - lo) / (hi - lo) * (base - top);
let s = `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true" focusable="false">`;
for (let v = lo; v <= hi + step / 2; v += step) s += `<line x1="${left}" x2="${right}" y1="${r1(y(v))}" y2="${r1(y(v))}" stroke="${v === lo ? c.axis : c.grid}" stroke-width="1"/><text x="${left - 8}" y="${r1(y(v) + 4)}" text-anchor="end" font-size="11" fill="${c.muted}">${f.format(v)}</text>`;
const every = (right - left) / rows.length < 34 ? 2 : 1;
const pts = rows.map((r, i) => [r1(x(i)), r1(y(r.value))]);
const d = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0]} ${p[1]}`).join('');
s += `<path d="${d}L${pts.at(-1)[0]} ${base}L${pts[0][0]} ${base}Z" fill="${c.c1}" fill-opacity=".12"/>`;
s += `<path d="${d}" fill="none" stroke="${c.c1}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`;
rows.forEach((r, i) => {
if ((rows.length - 1 - i) % every === 0) s += `<text x="${pts[i][0]}" y="${h - 8}" text-anchor="middle" font-size="11" fill="${c.muted}">${esc(r.label)}</text>`;
s += `<circle cx="${pts[i][0]}" cy="${pts[i][1]}" r="${i === rows.length - 1 ? 5 : 3.5}" fill="${c.c1}" stroke="${c.surface}" stroke-width="2"><title>${esc(r.label)}: ${esc(r.text)}</title></circle>`;
});
const last = pts.at(-1);
return s + `<text x="${last[0] - 10}" y="${last[1] - 12}" text-anchor="end" font-size="12" font-weight="700" fill="${c.ink}">${f.format(vals.at(-1))}</text></svg>`;
},
donut(rows, w, c, f, card) {
const total = rows.reduce((a, r) => a + r.value, 0), cols = [c.c1, c.c2, c.c3, c.c4];
let s = '<svg viewBox="0 0 170 170" width="170" height="170" aria-hidden="true" focusable="false"><g transform="rotate(-90 85 85)">', at = 0;
rows.forEach((r, i) => {
const p = r.value / total * 100;
s += `<circle cx="85" cy="85" r="64" fill="none" stroke="${cols[i % 4]}" stroke-width="26" pathLength="100" stroke-dasharray="${r1(p - .8)} ${r1(100 - p + .8)}" stroke-dashoffset="${r1(-at)}"><title>${esc(r.label)}: ${esc(r.text)}</title></circle>`;
at += p;
});
s += `</g><text x="85" y="84" text-anchor="middle" font-size="20" font-weight="750" fill="${c.ink}">${new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact' }).format(total)}</text><text x="85" y="103" text-anchor="middle" font-size="11" fill="${c.muted}">total</text></svg>`;
const legend = card.querySelector('[data-chart-legend]');
if (legend) legend.innerHTML = rows.map((r, i) => `<li><i style="background:${cols[i % 4]}"></i><span>${esc(r.label)}</span><b>${esc(r.text)}</b><span>${Math.round(r.value / total * 100)}%</span></li>`).join('');
return s;
}
};
document.querySelectorAll('[data-chart]').forEach(card => {
const type = card.dataset.chart, canvas = card.querySelector('[data-chart-canvas]'), wrap = card.querySelector('[data-chart-table]');
const btn = card.querySelector('[data-chart-toggle]'), table = wrap && wrap.querySelector('table');
if (!draw[type] || !canvas || !table) return;
const rows = [...table.tBodies[0].rows].map(tr => ({ label: tr.cells[0].textContent.trim(), value: parseFloat(tr.cells[1].dataset.value), text: tr.cells[1].textContent.trim() }));
const f = fmt[card.dataset.format] || fmt.number;
const colors = () => {
const css = getComputedStyle(card), v = (n, d) => css.getPropertyValue(n).trim() || d;
return { c1: v('--c1', '#2a78d6'), c2: v('--c2', '#eb6834'), c3: v('--c3', '#1baf7a'), c4: v('--c4', '#eda100'), ink: v('--ink', '#0f172a'),
muted: v('--muted', '#4b5768'), grid: v('--grid', '#e2e8f0'), axis: v('--muted', '#4b5768'), surface: v('--surface', '#fff') };
};
let last = '';
const render = () => {
const w = Math.max(240, Math.floor(canvas.clientWidth)), c = colors(), key = w + c.c1 + c.ink;
if (key === last) return; last = key; canvas.innerHTML = draw[type](rows, w, c, f, card);
};
render();
if ('ResizeObserver' in window) new ResizeObserver(render).observe(card);
wrap.classList.add(HIDE);
if (btn) {
btn.hidden = false;
btn.addEventListener('click', () => { const shown = !wrap.classList.toggle(HIDE); btn.textContent = shown ? 'Hide table' : 'Show table'; });
}
});
})();
About this set
Three chart cards drawn as inline SVG by under ninety lines of vanilla JavaScript, with no chart library. The bar chart shows orders by weekday with the value printed above each bar. The donut chart splits quarterly revenue by channel into four segments with a small gap between them, a total in the middle and a legend that repeats each value and share. The area chart tracks monthly recurring revenue across ten months with a light grid, compact dollar ticks and the latest value labelled. Every card holds a real data table, and the script reads that table to draw its chart, so the numbers live in one place. Once the chart is drawn the table is hidden visually but stays readable by screen readers, and a Show table button puts it back on screen for anyone who prefers the figures. If the script does not run, the table is simply shown. Charts redraw at the card’s real width through ResizeObserver, so text stays crisp from a phone to a wide screen, and each mark carries a native SVG title as a hover tooltip. Colours come from custom properties on the card.
What’s included
Bar, donut and area charts drawn from small data tables
No chart library: under ninety lines of vanilla JavaScript
Data table stays available to screen readers, with a Show table button
Table shows on its own when JavaScript is off
Redraws at the real card width so labels never shrink
Values printed on the bars, in the legend and on the last point
Accessibility
The SVG charts are hidden from assistive technology and the same numbers are in a captioned table with column and row headers, visually hidden but always present. The Show table button has visible text that changes to Hide table and a visible focus outline. The donut legend prints every value and share, so no series is identified by colour alone.
Customise it
Set --c1 to --c4 for the series colours and --ink, --muted and --grid for text and gridlines on .cw (Tailwind: the [--c1:...] properties on the card). Change data-format on a card to number, usd or usd-compact to switch the value formatting.