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; }
GoalSetNote
Scale with the container, keep the ratiowidth: 100%; height: autoRequires a viewBox
Fill a fixed box, crop the overflowpreserveAspectRatio="xMidYMid slice"Useful for cover images
Stretch to fitpreserveAspectRatio="none"Distorts strokes as well as shapes
Align to one cornerxMinYMin or xMaxYMaxWith meet
Scale with the fontSizes in emIcons inline with text
Remove the descender gapdisplay: blockOr vertical-align: middle
Draw outside the viewBoxoverflow: visibleOtherwise 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 createElementNS and the SVG namespace is required. Doing it with document.createElement produces elements the browser does not render.
  • Setting text with textContent is safe for data-driven labels; writing innerHTML with 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 needScaleWatch out
Numeric value on an axisLinearInclude zero for bars; do not for lines
CategoriesBand (ordinal)Centre the band, do not start at the edge
A continuous number axisLinear with nice ticksRound to 1, 2 or 5 times a power of ten
TimeTime scaleChoose the tick interval from the visible span
Values across orders of magnitudeLogarithmicNever plot zero or negatives
PercentagesLinear 0-100Show the % sign in the tick label
A diverging metricLinear centred on zeroThe 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));   // 1500

One 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.

Shapes and coordinates Animation, embedding and accessibility

Last refreshed 2026-09-18.