Responsive, fluid and data-driven SVG
Set preserveAspectRatio deliberately, size SVG with CSS, and hand-draw a chart — scales, axes, marks — from real data.
viewBox, preserveAspectRatio and CSS sizing
<!-- The three attributes that decide how an SVG scales -->
<svg viewBox="0 0 240 120"
preserveAspectRatio="xMidYMid meet"
width="100%" height="100%">
<rect x="0" y="0" width="240" height="120" fill="#eef2ff"/>
</svg>
<!-- meet: the whole graphic fits, letterboxed. The default. -->
<!-- slice: the graphic fills the box, cropped. -->
<!-- none: stretched, aspect ratio destroyed. -->
<!-- xMinYMin / xMidYMid / xMaxYMax and the xMidYMin-style combination
choose which part of the graphic is anchored. -->/* Let CSS own the size; the viewBox owns the coordinate system. */
.chart {
width: 100%;
height: auto; /* follows the viewBox's aspect ratio */
display: block; /* removes the inline-level baseline gap */
overflow: visible; /* let labels outside the viewBox show */
}
.icon {
width: 1.25em; /* scales with the surrounding font size */
height: 1.25em;
vertical-align: -0.2em; /* optical alignment with the text baseline */
}
/* A fixed aspect box that never distorts */
.thumb {
aspect-ratio: 16 / 9;
width: 100%;
}
.thumb > svg { width: 100%; height: 100%; display: block; }| Goal | Set | Note |
|---|---|---|
| Scale with the container, keep the ratio | width: 100%; height: auto | Requires a viewBox |
| Fill a fixed box, crop the overflow | preserveAspectRatio="xMidYMid slice" | Useful for cover images |
| Stretch to fit | preserveAspectRatio="none" | Distorts strokes as well as shapes |
| Align to one corner | xMinYMin or xMaxYMax | With meet |
| Scale with the font | Sizes in em | Icons inline with text |
| Remove the descender gap | display: block | Or vertical-align: middle |
| Draw outside the viewBox | overflow: visible | Otherwise it is clipped |
💡
A
viewBox is a coordinate system mapping, not a size. The numbers can be anything: work in convenient units such as 0-1000 and let CSS decide how large it renders. Strokes scale with it, so a stroke-width of 2 in a 1000-unit viewBox is a hairline.Drawing a chart by hand
// A small chart renderer: scales, axes, bars and labels, in about 60 lines.
function renderBarChart(svg, data, options = {}) {
const {
width = 600, height = 320,
padding = { top: 16, right: 16, bottom: 40, left: 56 }
} = options;
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
const max = Math.max(...data.map((d) => d.value));
const niceMax = Math.ceil(max / 100) * 100; // round up to a friendly axis
// Scales: data value -> pixel position
const x = (index) => padding.left + (index + 0.5) * (innerWidth / data.length);
const y = (value) => padding.top + innerHeight - (value / niceMax) * innerHeight;
const bandWidth = (innerWidth / data.length) * 0.6;
const ns = 'http://www.w3.org/2000/svg';
const el = (name, attrs = {}) => {
const node = document.createElementNS(ns, name);
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
return node;
};
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.replaceChildren();
// Gridlines and y labels
const ticks = 4;
for (let i = 0; i <= ticks; i++) {
const value = (niceMax / ticks) * i;
const lineY = y(value);
svg.append(el('line', {
x1: padding.left, x2: width - padding.right, y1: lineY, y2: lineY,
stroke: '#e2e8f0', 'stroke-width': 1
}));
const label = el('text', {
x: padding.left - 8, y: lineY + 4,
'text-anchor': 'end', 'font-size': 11, fill: '#64748b'
});
label.textContent = value.toLocaleString();
svg.append(label);
}
// Bars
data.forEach((datum, index) => {
const barHeight = padding.top + innerHeight - y(datum.value);
const bar = el('rect', {
x: x(index) - bandWidth / 2,
y: y(datum.value),
width: bandWidth,
height: Math.max(barHeight, 0),
rx: 3,
fill: datum.colour ?? '#6d28d9'
});
bar.append(el('title')).textContent = `${datum.label}: ${datum.value}`;
svg.append(bar);
// Category label
const label = el('text', {
x: x(index), y: height - padding.bottom + 20,
'text-anchor': 'middle', 'font-size': 11, fill: '#475569'
});
label.textContent = datum.label;
svg.append(label);
});
}
renderBarChart(document.getElementById('chart'), [
{ label: 'Q1', value: 820 },
{ label: 'Q2', value: 932 },
{ label: 'Q3', value: 901 },
{ label: 'Q4', value: 1290, colour: '#0ea5e9' }
]);- Creating elements with
createElementNSand the SVG namespace is required. Doing it withdocument.createElementproduces elements the browser does not render. - Setting text with
textContentis safe for data-driven labels; writinginnerHTMLwith a server value is an injection risk. - A
<title>child inside a shape provides a native tooltip and the accessible name for that shape. It is the simplest win in hand-built graphics. - Rebuild the whole SVG on a data change. Differential updates are only worth the complexity past a few thousand marks.
Scales and readable axes
// A small linear scale helper: the one abstraction worth having.
function linearScale(domain, range) {
const [d0, d1] = domain;
const [r0, r1] = range;
const span = d1 - d0 || 1;
const scale = (value) => r0 + ((value - d0) / span) * (r1 - r0);
scale.invert = (pixel) => d0 + ((pixel - r0) / (r1 - r0)) * span;
scale.ticks = (count = 5) =>
Array.from({ length: count + 1 }, (_, i) => d0 + (span / count) * i);
return scale;
}
// Use it for both axes: a bar chart's x is a band scale, not a linear one.
function bandScale(domain, range) {
const [r0, r1] = range;
const step = (r1 - r0) / domain.length;
const scale = (value) => r0 + step * domain.indexOf(value) + step / 2;
scale.bandwidth = () => step * 0.7;
return scale;
}
const x = bandScale(['Q1', 'Q2', 'Q3', 'Q4'], [56, 584]);
const y = linearScale([0, 1400], [280, 16]);
console.log(x('Q2'), y(932));| Chart need | Scale | Watch out |
|---|---|---|
| Numeric value on an axis | Linear | Include zero for bars; do not for lines |
| Categories | Band (ordinal) | Centre the band, do not start at the edge |
| A continuous number axis | Linear with nice ticks | Round to 1, 2 or 5 times a power of ten |
| Time | Time scale | Choose the tick interval from the visible span |
| Values across orders of magnitude | Logarithmic | Never plot zero or negatives |
| Percentages | Linear 0-100 | Show the % sign in the tick label |
| A diverging metric | Linear centred on zero | The zero line needs emphasis |
// Choosing a readable axis maximum
function niceMax(value) {
if (value <= 0) return 1;
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalised = value / magnitude;
const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10;
return step * magnitude;
}
console.log(niceMax(932)); // 1000
console.log(niceMax(1290)); // 2000 - arguably too much headroom
console.log(niceMax(12900)); // 20000
// A gentler alternative: round up to a multiple of a chosen step.
const roundUp = (value, step) => Math.ceil(value / step) * step;
console.log(roundUp(1290, 250)); // 1500One accessibility habit that applies to every hand-built chart: give the SVG role="img" and an aria-label summarising it, and put the data in a table nearby. Then a <title> on each mark gives pointer users the exact value. Those three additions take ten minutes and cover the common cases.
FAQ
Why does my SVG have a gap underneath?
An inline SVG is an inline-level box, so it sits on the text baseline and leaves room for descenders. Set
display: block on the SVG, or vertical-align: middle if it must stay inline.Should I hand-draw charts in SVG?
For a small, static or highly custom graphic, yes — it is a few dozen lines and the result is crisp, styleable and accessible. For anything interactive with many points, use a charting library or canvas; you will otherwise spend your time reimplementing axes, tooltips and hit testing.
Related
Shapes and coordinates Animation, embedding and accessibility
Last refreshed 2026-09-18.