Line charts with explicit gaps and matching data tables
Choose the surface around the chart’s task: CSS suits the simple bar layout linked below, SVG exposes explicit line paths, and canvas supplies a bitmap drawing surface. Keep the geometry and visible table tied to one dataset, break lines at missing observations, and retain measured zeroes as points.
Start with the question the line answers
The example shows six evenly spaced fictional days. Daily change in crates is Monday 2, Tuesday 0, Wednesday missing, Thursday −2, Friday 3 and Saturday 0. These synthetic values expose a gap, two zeroes and a negative value without suggesting a real trend.
Both original renderers use geometry from the same model. The selector changes the surface, not the dataset. No chart package is imported.
Before adapting this example, decide whether the horizontal spacing represents equal intervals. Here it does because the example explicitly uses consecutive days. A series with irregular timestamps needs a time-based coordinate rule. Reusing these equal index positions would conceal that different elapsed time. This implementation does not include that rule.
Choose the rendering surface deliberately
CSS is a reasonable design starting point when the picture is a small arrangement of styled boxes, as in the bar-chart example. For this line example, SVG makes the chosen points and line segments explicit in the document. This is an implementation preference for a reviewable six-point example, not a claim that SVG is always the right chart technology.
SVG paths are sequences of commands: M moves without drawing a connection, and L draws a line to the next position1. The canvas renderer follows that move-then-line pattern; the SVG renderer uses a separate polyline for each run and circles for individual observed points. No curve smoothing or interpolation is added.
Canvas supplies the second rendering surface. Its bitmap does not expose drawn objects as semantic HTML; its coordinate dimensions and displayed CSS size are separate2. The component keeps a visible HTML table outside that surface. Its canvas has declared dimensions of 640 by 300 and is displayed proportionally within the container.
Choose a rendering surface around the update rate, data size and interaction the application needs. This six-point example emphasizes explicit geometry and a shared value model. It supplies no size threshold for switching technologies and does not select a renderer by a performance score.
Split at missing observations
The model uses null for Wednesday’s unobserved value. Geometry generation leaves its vertical coordinate empty. Segment construction ends the current run at that point and begins another run at the next numeric observation. The designed result is one Monday–Tuesday segment and one Thursday–Saturday segment.
Preserve Wednesday’s horizontal position and its table row. Removing the record would change the meaning of the timeline. Drawing directly from Tuesday to Thursday would imply an unbroken connection across an unobserved day. Whether an application should interpolate is an explicit data policy; this example chooses not to.
The zero control changes the synthetic data; it does not authorize imputing missing production values. Unchecking it returns to the original gap.
Plot zero and negative values on the declared scale
The vertical domain is fixed at −4 to 4. The zero axis is at coordinate 140. Each value changes the vertical coordinate by 27.5 units in the opposite direction, so positive values sit above that axis and negative values below it. Monday’s 2 maps to 85; Thursday’s −2 maps to 195. These are the example’s arithmetic rules.
Tuesday and Saturday map to the zero axis and still receive point markers. Missing Wednesday receives no point. The table spells out every value, while the coordinate formula makes the corresponding position inspectable. A zero is not an empty string, absent mark or missing row.
Keep scale labels and the unit near the graphic. The table preserves exact source values, while the line emphasizes their order and relative position. Text should convey the important information in the graphic, as W3C’s complex-images guidance explains3. Here the nearby description identifies the scale and missing-data convention, and the table provides the values.
Separate data checks from rendering checks
Compare the table with the values supplied to each renderer, then inspect the SVG runs and markers. Both surfaces retain their input values as document metadata, even when one is hidden. That makes the shared data transformation inspectable. It does not read back canvas pixels or prove that labels are legible.
CSS scales the displayed surfaces within their available width; the SVG view box and canvas bitmap remain 640 by 300. Both receive the same geometry, but they remain different rendering surfaces. Shared inputs do not establish identical pixels or label legibility, and this component run did not test a narrower viewport.
The component
Original code, MIT licensed. Save this HTML locally; it contains both renderers, the table and two controls. Open the separate instrumented demo to compare the gap and zero cases.
<!doctype html>
<html lang="en">
<head>
<!-- SPDX-License-Identifier: MIT -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Line-chart gaps, zeroes and a data table</title>
<style>
body { font: 1rem/1.5 system-ui; margin: 1rem; max-width: 40rem; }
svg, canvas { display: block; width: 100%; height: auto; }
[hidden] { display: none; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: .4rem; border-bottom: 1px solid #aab; }
:focus-visible { outline: 3px solid #a43b00; outline-offset: 2px; }
</style>
</head>
<body>
<label for="surface">Renderer</label>
<select id="surface">
<option value="svg">SVG</option>
<option value="canvas">Canvas</option>
</select>
<label>
<input id="zero" type="checkbox">
Treat Wednesday as zero
</label>
<p>Fictional daily change in crates: scale −4 to 4. A missing day breaks the line.</p>
<svg id="svg" viewBox="0 0 640 300" aria-hidden="true"></svg>
<canvas id="canvas" width="640" height="300" aria-hidden="true" hidden></canvas>
<table>
<caption>Exact values used by the selected renderer</caption>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Change in crates</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
<script>
const original = [["Monday", 2], ["Tuesday", 0], ["Wednesday", null], ["Thursday", -2], ["Friday", 3], ["Saturday", 0]];
const surface = document.querySelector("#surface");
const zero = document.querySelector("#zero");
const svg = document.querySelector("#svg");
const canvas = document.querySelector("#canvas");
const rows = document.querySelector("#rows");
const context = canvas.getContext("2d");
function render() {
const values = original.map(([day, value]) => [day, value === null && zero.checked ? 0 : value]);
const points = values.map(([day, value], i) => ({day, value, x: 40 + i * 112, y: value === null ? null : 140 - value * 27.5}));
const segments = [];
let segment = [];
for (const point of points) {
if (point.value === null) {
if (segment.length) segments.push(segment);
segment = [];
} else segment.push(point);
}
if (segment.length) segments.push(segment);
svg.replaceChildren();
rows.replaceChildren();
context.clearRect(0, 0, 640, 300);
const add = (name, attributes, text = "") => {
const node = document.createElementNS("http://www.w3.org/2000/svg", name);
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
node.textContent = text;
svg.append(node);
};
context.font = "14px system-ui";
context.textAlign = "left";
context.strokeStyle = "#75828b";
context.fillStyle = "#172b3a";
for (const tick of [-4, 0, 4]) {
const y = 140 - tick * 27.5;
add("line", {x1: 40, x2: 600, y1: y, y2: y, stroke: "#ccd4dc"});
add("text", {x: 8, y: y + 5, "font-size": 14}, String(tick));
context.beginPath();
context.moveTo(40, y);
context.lineTo(600, y);
context.stroke();
context.fillText(String(tick), 8, y + 5);
}
context.strokeStyle = "#185f8c";
context.lineWidth = 3;
for (const run of segments) {
add("polyline", {points: run.map(p => `${p.x},${p.y}`).join(" "), fill: "none", stroke: "#185f8c", "stroke-width": 3});
context.beginPath();
run.forEach((p, i) => i ? context.lineTo(p.x, p.y) : context.moveTo(p.x, p.y));
context.stroke();
}
for (const point of points) {
if (point.value !== null) {
add("circle", {cx: point.x, cy: point.y, r: 4, fill: "#185f8c"});
context.beginPath();
context.arc(point.x, point.y, 4, 0, Math.PI * 2);
context.fill();
}
add("text", {x: point.x, y: 280, "text-anchor": "middle", "font-size": 14}, point.day.slice(0, 3));
context.textAlign = "center";
context.fillText(point.day.slice(0, 3), point.x, 280);
const row = document.createElement("tr");
row.innerHTML = `
<th scope="row">${point.day}</th>
<td>${point.value === null ? "Missing" : point.value}</td>`;
rows.append(row);
}
svg.dataset.values = canvas.dataset.values = JSON.stringify(values);
svg.toggleAttribute("hidden", surface.value !== "svg");
canvas.hidden = surface.value !== "canvas";
}
surface.addEventListener("change", render);
zero.addEventListener("change", render);
render();
</script>
</body>
</html>
Walk through the gap and zero cases
Keep the data policy visible while changing the renderer:
- Wednesday is missing. With SVG selected, geometry leaves Wednesday’s vertical coordinate empty. The first segment ends after Tuesday; another starts at Thursday. The day and its table row remain in place.
- Wednesday becomes zero. The control changes the synthetic value to numeric zero. Its coordinate joins the zero axis, allowing one unbroken segment and a third zero point.
- The gap is restored. The original missing value returns. Segment construction again separates the two runs instead of drawing across the absent observation.
- Canvas is selected. The drawing function receives the same points and runs, moving to the start of each run rather than connecting across the gap. The visible table stays outside the bitmap.
- The renderer changes. The selector hides one surface and reveals the other without changing the table values. Compare those values separately from each surface’s pixels.
Migration
Preserve the gap policy, scale and value alternative when replacing a chart. This route contains new code and synthetic data; the short /pure-css-line-graph/ path redirects here. No former image sprite, stylesheet or dataset is reproduced.
Not covered on this page
- Accessibility certification or a no-JavaScript rendering path.
- Canvas pixel equivalence, performance superiority or support for large datasets.
- Irregular-time scaling, interpolation, interactive point selection or curve smoothing.
- Print layouts, real-business interpretation or universal browser coverage.
Sources
-
MDN, Paths — SVG tutorial, modified 31 August 2026. https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorials/SVG_from_scratch/Paths (opened 24 October 2026). ↩
-
MDN, The canvas HTML graphics element, modified 24 April 2026. https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/canvas (opened 24 October 2026). ↩
-
W3C WAI, Complex Images, updated 8 April 2026. https://www.w3.org/WAI/tutorials/images/complex/ (opened 24 October 2026). ↩
How we test: methodology. Found a mistake? Corrections.