A box plot (or box-and-whisker plot) is one of the most powerful tools in exploratory data analysis. In one compact graphic, it reveals the median, spread, skewness, and outliers of a dataset — all from just five numbers.
In this tutorial, I'll walk through building a complete box plot calculator from scratch using vanilla JavaScript and SVG. No D3, no Chart.js, no dependencies. By the end, you'll understand:
If you want to skip the code and use a ready-made tool, I built ** aiboxplot.com** — a free, no-sign-up platform that does all of this plus AI analysis, multi-dataset comparison, and one-click export to PNG/SVG/CSV. The rendering engine is
Try it now →[aiboxplot.com]
Every box plot is built on five values:
| Metric | Description |
|---|---|
| Minimum | |
| The smallest value that's not an outlier | |
| Q1 (first quartile) | |
| 25th percentile — 25% of data falls below this | |
| Median (Q2) | |
| 50th percentile — the middle value | |
| Q3 (third quartile) | |
| 75th percentile | |
| Maximum | |
| The largest value that's not an outlier |
Plus one more: the Interquartile Range (IQR) = Q3 − Q1. This measures the spread of the middle 50% and is the key to detecting outliers.
The most common mistake in box plot calculators is using the wrong quartile method. There are at least 9 different ways to compute percentiles (see Hyndman & Fan, 1996). I use linear interpolation (method 7), which is the default in Python's NumPy and pandas:
/**
* Compute a percentile value using linear interpolation.
* @param {number[]} sorted - Sorted array of numbers
* @param {number} p - Percentile as a fraction (0.25 = Q1, 0.5 = median, 0.75 = Q3)
*/
function percentile(sorted, p) {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const n = sorted.length;
const h = (n - 1) * p; // Real-valued index
const lo = Math.floor(h);
const hi = Math.ceil(h);
if (lo === hi) return sorted[lo];
// Linear interpolation between lo and hi
return sorted[lo] + (h - lo) * (sorted[hi] - sorted[lo]);
}
Why this matters: for a dataset of [1, 2, 3, 4, 5]
, the median is 3. But for [1, 2, 3, 4]
, using simple rounding gives 2, while linear interpolation correctly gives 2.5. These small differences compound when you're analyzing real data.
John Tukey's fences method is elegant, simple, and still the industry standard:
function calculateStats(data, iqrMultiplier = 1.5) {
const sorted = [...data].sort((a, b) => a - b);
const n = sorted.length;
const mean = sorted.reduce((a, b) => a + b, 0) / n;
const q1 = percentile(sorted, 0.25);
const median = percentile(sorted, 0.5);
const q3 = percentile(sorted, 0.75);
const iqr = q3 - q1;
// Tukey's fences
const lowerFence = q1 - iqrMultiplier * iqr;
const upperFence = q3 + iqrMultiplier * iqr;
// Values outside fences are potential outliers
const outliers = sorted.filter(v => v < lowerFence || v > upperFence);
// Non-outlier range defines the whiskers
const nonOutliers = sorted.filter(v => v >= lowerFence && v <= upperFence);
const min = nonOutliers[0];
const max = nonOutliers[nonOutliers.length - 1];
return { min, q1, median, q3, max, iqr, mean, lowerFence, upperFence, outliers, count: n };
}
The 1.5
multiplier is the standard. Use 2.0
for a more conservative detection, or 3.0
for extreme outliers only. At ** aiboxplot.com**, you can switch between these thresholds in real time.
Now for the fun part — turning numbers into a chart. Here's the core SVG rendering logic:
function renderBoxPlot(stats, width = 500, height = 200) {
const { min, q1, median, q3, max, outliers } = stats;
// Scale setup
const padL = 50, padR = 30, padT = 20, padB = 40;
const plotW = width - padL - padR;
const plotH = height - padT - padB;
const dataMin = Math.min(min, ...outliers);
const dataMax = Math.max(max, ...outliers);
const range = dataMax - dataMin || 1;
const paddedMin = dataMin - range * 0.08;
const paddedMax = dataMax + range * 0.08;
const paddedRange = paddedMax - paddedMin;
// Map data value → SVG x-coordinate
const sx = (v) => padL + plotW * ((v - paddedMin) / paddedRange);
const boxY = padT + plotH * 0.2;
const boxH = plotH * 0.6;
const yc = padT + plotH / 2;
const color = "#2563eb";
return `
<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<!-- Axis -->
<line x1="${padL}" y1="${padT + plotH}"
x2="${padL + plotW}" y2="${padT + plotH}"
stroke="#d1d5db" stroke-width="0.5"/>
<!-- Left whisker -->
<line x1="${sx(min)}" y1="${yc - boxH / 4}"
x2="${sx(min)}" y2="${yc + boxH / 4}"
stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
<line x1="${sx(min)}" y1="${yc}"
x2="${sx(q1)}" y2="${yc}"
stroke="${color}" stroke-width="0.5" stroke-dasharray="3 3"/>
<!-- Box (Q1 to Q3) -->
<rect x="${sx(q1)}" y="${boxY}"
width="${sx(q3) - sx(q1)}" height="${boxH}"
fill="${color}" opacity="0.15" stroke="${color}" stroke-width="1.5" rx="2"/>
<!-- Median line -->
<line x1="${sx(median)}" y1="${boxY}"
x2="${sx(median)}" y2="${boxY + boxH}"
stroke="${color}" stroke-width="2" stroke-linecap="round"/>
<!-- Right whisker -->
<line x1="${sx(q3)}" y1="${yc}"
x2="${sx(max)}" y2="${yc}"
stroke="${color}" stroke-width="0.5" stroke-dasharray="3 3"/>
<line x1="${sx(max)}" y1="${yc - boxH / 4}"
x2="${sx(max)}" y2="${yc + boxH / 4}"
stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
<!-- Outlier dots -->
${outliers.map(o => `<circle cx="${sx(o)}" cy="${yc}" r="4"
fill="none" stroke="${color}" stroke-width="1.5"/>`).join("")}
</svg>
`;
}
The key insight: SVG is just XML. A box plot is just <line>
, <rect>
, and <circle>
elements — no canvas, no external libraries.
// Example: exam scores from two classes
const dataA = [72, 85, 78, 90, 65, 88, 76, 92, 70, 84, 79, 86, 74, 91, 68, 83, 77, 89, 71, 95];
const dataB = [60, 62, 58, 70, 55, 68, 72, 64, 68, 61, 75, 66, 53, 77, 69, 71, 59, 63, 67, 74];
const statsA = calculateStats(dataA);
const statsB = calculateStats(dataB);
console.log("Class A — Median:", statsA.median, "IQR:", statsA.iqr);
console.log("Class B — Median:", statsB.median, "IQR:", statsB.iqr);
console.log("Outliers in A:", statsA.outliers);
console.log("Outliers in B:", statsB.outliers);
document.getElementById("chart-a").innerHTML = renderBoxPlot(statsA);
document.getElementById("chart-b").innerHTML = renderBoxPlot(statsB);
Side-by-side comparison instantly reveals:
This kind of insight takes seconds with a box plot — and pages of text to describe otherwise.
Building a working box plot is straightforward. Building a great one takes more:
I built ** aiboxplot.com** to solve all of this — with 8 chart types, AI chat, and zero sign-up required. The rendering engine behind it is
Make your first box plot →[aiboxplot.com]
Did this help? Let me know in the comments, or show me what you build! And if you want a ready-made solution for your next data project, give aiboxplot.com a try.