CSS bar charts with matching data tables

How-to Last tested 24 October 2026

Build the bar chart and its visible data table from one dataset, preserving each label, unit and value in both outputs. Treat missing values, zeroes and negative numbers as three different cases, and check the rendered values before trusting the picture.

Define the data before drawing

The example uses five fictional depots and a weekly change measured in crates. Cedar has 24, Juniper has 0, Maple has −12, Willow is missing and Birch has 8. These synthetic values expose the renderer’s cases without suggesting an operating business or a real trend.

Represent an unobserved value as null in this small model. Keep a measured zero as numeric zero. Do not use an expression that replaces every false-like value with a default: the rendering rule must distinguish value === null from value === 0. In production, validate incoming data before it reaches either renderer. This example accepts its own fixed model; it is not a general-purpose import validator.

Use an explicit unit in the nearby explanation and table caption. A bar labelled “24” is incomplete if one reader interprets it as a count and another as a percentage. Preserve the same category order in the chart and table so a reader can compare the two without mentally sorting either view.

Give both signs the same scale

The declared domain is −30 to 30, with zero halfway across each track. For a nonzero value, the width percentage is the absolute value divided by 60, multiplied by 100. The left percentage is 50 plus the negative part of the value divided by 60, multiplied by 100. These are the formulas implemented in this example.

For Cedar, that produces a width of 40 percent starting at 50 percent. Maple’s −12 produces a width of 20 percent starting at 30 percent. Both bars meet the same zero position; their signs are also written in the labels and table. The chosen colors are supplementary. The result is a comparison of signed changes, not a proportion-of-total chart.

Keep the scale stable when comparing the mixed dataset with the all-zero dataset. A reader should not have to infer that identical-looking tracks represent different ranges. If real data can exceed the declared limits, define an explicit rescaling or out-of-range policy. The current implementation does neither, because its fixed values stay within the declared domain.

Render zero and missing separately

A zero-width rectangle is a poor way to explain that a zero was actually measured. The example therefore has a separate zero-marker branch, while retaining the written value “0”. A missing value follows another branch containing “Missing observation”. It receives no magnitude bar and is labelled “Missing” in the table.

The scenario selector supplies an all-zero case. It does not define a rule for filling gaps in production data; returning to Mixed values restores the five original records.

Do not turn a missing item into an empty category label or silently remove its row. The question “Was Willow measured?” is different from “Did Willow change by zero?” Both the visual treatment and the table need to preserve that difference.

Make the data alternative part of the page

The table is adjacent to the chart, with a caption, a header row and named depot rows. The chart is supplementary and marked hidden from the accessibility tree; the visible table carries the exact values. Both outputs still come from the same records, with their display roles kept separate.

Provide the chart’s information in text, including its scale, relationships and relevant trends; W3C’s complex-images guidance includes structured descriptions and tables1. Use that alternative to preserve the question the graphic answers, not just to repeat its title.

For a real report, add the conclusion the reader needs, using the same data. Here a bounded description is enough: the synthetic set contains two positive changes, one measured zero, one negative change and one missing observation. Avoid inventing a reason for those values. They were chosen to expose branches in the renderer, not to explain a business outcome.

Check the two rendered outputs

Read each chart label and compare it with the corresponding table cell. The component also stores the value on each chart item, making its input inspectable without adding a public diagnostic panel. Matching those values checks two document outputs. It is not a pixel test, and it cannot prove that the source dataset was correct in the first place.

The width and left-position rules can be inspected separately from the visible picture. A correct percentage string cannot reveal every clipping, overlap or readability problem. Keep source-data validation, DOM equivalence and visual rendering as distinct responsibilities.

The component

Original code, MIT licensed. CSS draws the bars; JavaScript builds the chart and table from the fixed model. Save this HTML locally or open the separate instrumented demo to switch between its 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>Signed bars and their data table</title>
    <style>
      body { font: 1rem/1.5 system-ui; margin: 1rem; max-width: 40rem; }
      .item { margin-block: .75rem; }
      .track { position: relative; height: 2rem; background: #edf1f5; }
      .track::before { content: ""; position: absolute; left: 50%; height: 100%; border-left: 1px solid #334; }
      .bar { position: absolute; top: .35rem; height: 1.3rem; background: #185f8c; }
      .zero { position: absolute; left: 50%; height: 100%; border-left: 4px solid #a43b00; }
      .missing { position: relative; background: white; }
      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="scenario">Dataset</label>
    <select id="scenario">
      <option value="mixed">Mixed values</option>
      <option value="zero">All zeroes</option>
    </select>
    <p>Weekly change in crates. The chart scale runs from −30 to 30; zero is centered.</p>
    <div id="chart" aria-hidden="true"></div>
    <table>
      <caption>Exact weekly change for five fictional depots</caption>
      <thead>
        <tr>
          <th scope="col">Depot</th>
          <th scope="col">Change in crates</th>
        </tr>
      </thead>
      <tbody id="rows"></tbody>
    </table>
    <script>
      const mixed = [["Cedar", 24], ["Juniper", 0], ["Maple", -12], ["Willow", null], ["Birch", 8]];
      const scenario = document.querySelector("#scenario");
      const chart = document.querySelector("#chart");
      const rows = document.querySelector("#rows");
      function render() {
        chart.replaceChildren();
        rows.replaceChildren();
        for (const [name, original] of mixed) {
          const value = scenario.value === "zero" ? 0 : original;
          const text = value === null ? "Missing" : String(value);
          const item = document.createElement("div");
          item.className = "item";
          item.dataset.value = text;
          item.innerHTML = `
            <span>${name}: ${text}</span>
            <div class="track"></div>`;
          const mark = document.createElement("span");
          if (value === null) {
            mark.className = "missing";
            mark.textContent = "Missing observation";
          } else if (value === 0) {
            mark.className = "zero";
          } else {
            mark.className = "bar";
            mark.style.width = `${Math.abs(value) / 60 * 100}%`;
            mark.style.left = `${50 + Math.min(value, 0) / 60 * 100}%`;
          }
          item.querySelector(".track").append(mark);
          chart.append(item);
          const row = document.createElement("tr");
          row.innerHTML = `
            <th scope="row">${name}</th>
            <td>${text}</td>`;
          rows.append(row);
        }
      }
      scenario.addEventListener("change", render);
      render();
    </script>
  </body>
</html>

Walk through each value branch

The same rendering path handles all five records:

Migration

This legacy-linked route now has an original implementation of the chart-and-table task. It uses no former article, image, stylesheet or dataset. “CSS chart” describes the visual technique here; JavaScript generates both outputs from the same records. This guide does not promise a no-JavaScript rendering path or compatibility with an earlier example.

Not covered on this page

Sources

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