Responsive tables with stable headers and row selection

How-to Last tested 24 October 2026

Keep the data in one native table, give each value a row and column header, and put horizontal scrolling on a surrounding region. Add sticky column headers as a presentation layer, then express selection through a checkbox and a written state as well as color.

Start with the relationships

The example contains twelve fictional depots, planned quantities, delivered quantities and selection states. The figures describe no company or production dataset. One native table holds the comparison, so layout changes do not require a second copy of the data.

Identify the comparison before choosing a mobile layout. Here, the reader needs to compare planned and delivered crates across depots. Keep those quantities in consistent columns. A caption names the dataset and unit. Each depot is a row header; each quantity has a column header. Use th with scope="row" and scope="col" for this simple arrangement1.

Do not expand this example into grouped, merged or irregular headers without a separate design. Its structure is deliberately simple: one header row and twelve body rows. A report with quarterly column groups or cells spanning several rows needs its own association checks. The current example cannot stand in for those shapes.

Give the table a bounded scroll region

The component places the table inside a 320-pixel-wide container with a maximum height of 330 pixels and overflow: auto. The table has a minimum width of 880 pixels. The region can shrink to fit its parent without changing the data or converting table cells into cards. These are declared example settings, not universal breakpoints.

overflow: auto makes overflowing content available through scrolling2. The container’s scroll width and client width identify whether content extends beyond the available space. Keeping those dimensions separate also distinguishes the table’s minimum width from its surrounding region.

The region has a name and a keyboard focus target. It uses native browser scrolling rather than intercepting arrow keys or prescribing a fixed step size. Once a checkbox holds focus, that input retains its native action. If the surrounding application adds shortcuts, test that they do not take over either scrolling or checkbox activation.

Do not hide the horizontal overflow to make a screenshot look tidy. For this design, the off-screen columns remain part of the comparison. If that comparison is too demanding on a narrow screen, revisit the content or add a separately tested view. This guide does not claim that scrolling beats stacking for every table.

Keep header positioning inside the same context

Each column header uses position: sticky with a top inset of zero. The header also has an opaque background and an explicit stacking level. The design intent is to keep labels distinguishable from the body rows beneath them during vertical scrolling.

Sticky positioning uses a scrolling ancestor and needs a non-auto inset on the relevant axis3. Check the ancestors when a header attaches to an unexpected boundary. Adding another clipping or scrolling wrapper can change which context matters.

Compare the header’s top edge with the region boundary after scrolling vertically. That separates header position from the body’s changing position. The opaque background keeps underlying body content from becoming part of the header’s appearance. Row headers are not frozen horizontally; adding a pinned first column introduces another overlapping layer to design.

Make selection an explicit state

Each row contains a native checkbox labelled with its depot name. The row’s final cell contains either “Selected” or “Not selected”. A separate summary lists the selected depots. The background tint is supplementary; it is not the only designed indicator.

One change handler updates the row class, written state and summary from the checkbox’s checked property. There is no separate hidden selection array to synchronize. The summary lists selected depot names, while each row keeps its own written state beside the input. Inspect all three when changing the selection behavior.

Selection is temporary in this example. The authored initial state is unchecked, and there is no persistence mechanism. There is no bulk action, server request, persistence, sorting or filtering. In a real table, specify whether selection follows a stable record identifier when rows move or disappear. Do not silently equate a row’s current position with its identity.

The component

Original code, MIT licensed. Save this HTML locally; it contains the table, styles and selection behavior with no dependency. Use the separate instrumented demo to work through scrolling and selection.

<!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>Responsive depot table</title>
    <style>
      body { font: 1rem/1.5 system-ui; margin: 1rem; }
      .region { max-width: 100%; width: 320px; max-height: 330px; overflow: auto; }
      table { width: 880px; border-collapse: separate; border-spacing: 0; }
      th, td { padding: .6rem; border-bottom: 1px solid #aab; text-align: left; }
      thead th { position: sticky; top: 0; z-index: 1; background: #eef3f7; }
      .selected { background: #e1f0ff; }
      :focus-visible { outline: 3px solid #a43b00; outline-offset: 2px; }
    </style>
  </head>
  <body>
    <p id="selection">Selected depots: none</p>
    <div class="region" tabindex="0" role="region" aria-label="Depot crate comparison">
      <table>
        <caption>Fictional depot crates: planned, delivered and selected</caption>
        <thead>
          <tr>
            <th scope="col">Depot</th>
            <th scope="col">Planned</th>
            <th scope="col">Delivered</th>
            <th scope="col">Selection</th>
          </tr>
        </thead>
        <tbody id="rows"></tbody>
      </table>
    </div>
    <script>
      const names = ["Cedar", "Juniper", "Maple", "Willow", "Birch", "Alder",
        "Aspen", "Elm", "Larch", "Oak", "Pine", "Spruce"];
      const rows = document.querySelector("#rows");
      for (const [index, name] of names.entries()) {
        const row = document.createElement("tr");
        row.innerHTML = `
          <th scope="row">${name}</th>
          <td>${20 + index * 3}</td>
          <td>${18 + index * 2}</td>
          <td>
            <label>
              <input type="checkbox" aria-label="Select ${name}">
              <span>Not selected</span>
            </label>
          </td>`;
        rows.append(row);
      }
      rows.addEventListener("change", () => {
        const selected = [];
        for (const row of rows.rows) {
          const checked = row.querySelector("input").checked;
          row.classList.toggle("selected", checked);
          row.querySelector("span").textContent = checked ? "Selected" : "Not selected";
          if (checked) selected.push(row.querySelector("th").textContent);
        }
        document.querySelector("#selection").textContent =
          `Selected depots: ${selected.join(", ") || "none"}`;
      });
    </script>
  </body>
</html>

Walk through scrolling and selection

The awkward cases use the same table and state model:

Migration

Preserve the table’s comparison and selection requirements when replacing an integration. This route previously carried links associated with Tablecloth; the current code and prose are new and promise no compatibility with its options or authorship continuity.

Not covered on this page

Sources

  1. W3C WAI, Tables with Two Headers, updated 27 July 2019. https://www.w3.org/WAI/tutorials/tables/two-headers/ (opened 24 October 2026). ↩

  2. MDN, overflow, modified 27 August 2026. https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overflow (opened 24 October 2026). ↩

  3. MDN, position, modified 26 July 2026. https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/position (opened 24 October 2026). ↩

How we test: methodology. Found a mistake? Corrections.