Skip to content

COMPONENT

Table

Rows and columns, and the two things a table has to survive: a container too narrow for it, and a reader looking for one row out of two hundred. The first is CSS and needs no script. The second is one optional directive over a table that already works without it.

A table, and the same table below 30rem

Squeeze the window. Under two readable columns' worth of room it stops being a grid and becomes a list of records.

Release Platform Size Published
v1.4.0 linux amd64 6.1 MB 2026-08-24
v1.4.0 darwin arm64 5.8 MB 2026-08-24
v1.3.2 windows amd64 6.4 MB 2026-07-19
HTML
<div class="table-wrap">
  <table class="table table-zebra table-stack">
    <thead>
      <tr>
        <th scope="col">Release</th>
        <th scope="col">Platform</th>
        <th scope="col" class="table-num">Size</th>
        <th scope="col">Published</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row" data-label="Release">v1.4.0</th>
        <td data-label="Platform">linux amd64</td>
        <td class="table-num" data-label="Size">6.1 MB</td>
        <td data-label="Published">2026-08-24</td>
      </tr>
      <tr>
        <th scope="row" data-label="Release">v1.4.0</th>
        <td data-label="Platform">darwin arm64</td>
        <td class="table-num" data-label="Size">5.8 MB</td>
        <td data-label="Published">2026-08-24</td>
      </tr>
      <tr>
        <th scope="row" data-label="Release">v1.3.2</th>
        <td data-label="Platform">windows amd64</td>
        <td class="table-num" data-label="Size">6.4 MB</td>
        <td data-label="Published">2026-07-19</td>
      </tr>
    </tbody>
  </table>
</div>

Type in it. Click a header three times.

Sortable and filterable. Turn JavaScript off and the sort buttons are not there at all β€” the table is.

v1.4.0 linux amd64 6.1 MB 2026-08-24
v1.4.0 darwin arm64 5.8 MB 2026-08-24
v1.3.2 windows amd64 6.4 MB 2026-07-19
v1.3.2 linux arm64 5.9 MB 2026-07-19
v1.2.0 darwin amd64 5.6 MB 2026-06-02
HTML
<div class="flow flow-tight" x-ds-table>
  <input class="input" type="search" data-ds-search placeholder="Filter" aria-label="Filter the rows below" />

  <div class="table-wrap">
    <table class="table table-pin table-stack">
      <thead>
        <tr>
          <th scope="col"><button class="table-sort" type="button" data-ds-pending>Release</button></th>
          <th scope="col"><button class="table-sort" type="button" data-ds-pending>Platform</button></th>
          <th scope="col" class="table-num" data-sort="number">
            <button class="table-sort" type="button" data-ds-pending>Size</button>
          </th>
          <th scope="col"><button class="table-sort" type="button" data-ds-pending>Published</button></th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <th scope="row" data-label="Release">v1.4.0</th>
          <td data-label="Platform">linux amd64</td>
          <td class="table-num" data-label="Size">6.1 MB</td>
          <td data-label="Published">2026-08-24</td>
        </tr>
        <tr>
          <th scope="row" data-label="Release">v1.4.0</th>
          <td data-label="Platform">darwin arm64</td>
          <td class="table-num" data-label="Size">5.8 MB</td>
          <td data-label="Published">2026-08-24</td>
        </tr>
        <tr>
          <th scope="row" data-label="Release">v1.3.2</th>
          <td data-label="Platform">windows amd64</td>
          <td class="table-num" data-label="Size">6.4 MB</td>
          <td data-label="Published">2026-07-19</td>
        </tr>
        <tr>
          <th scope="row" data-label="Release">v1.3.2</th>
          <td data-label="Platform">linux arm64</td>
          <td class="table-num" data-label="Size">5.9 MB</td>
          <td data-label="Published">2026-07-19</td>
        </tr>
        <tr>
          <th scope="row" data-label="Release">v1.2.0</th>
          <td data-label="Platform">darwin amd64</td>
          <td class="table-num" data-label="Size">5.6 MB</td>
          <td data-label="Published">2026-06-02</td>
        </tr>
      </tbody>
      <tbody class="table-empty" hidden>
        <tr><td colspan="4">Nothing matched that.</td></tr>
      </tbody>
    </table>
  </div>
</div>

Reference

Read from components/table.css when this page was built, so it cannot fall behind the stylesheet.

CLASSES.table-wrap.table.table-num.table-zebra.table-pin.table-stack.table-sort.table-empty
SETTINGSSet the first on one element; a theme sets the second for all of them.--table-pad β€” falls back to --ds-pad-cell--table-surface-alt β€” falls back to --ds-sunk--table-surface β€” falls back to --ds-ground--table-shadow β€” falls back to --ds-shadow-raised
READS--ds-accent--ds-chevron-leg--ds-focus-ring--ds-focus-width--ds-font-display--ds-gap--ds-gap-loose--ds-gap-tight--ds-hairline--ds-hit-min--ds-ink--ds-ink-faint--ds-ink-read--ds-motion-fast--ds-radius-field--ds-rule--ds-rule-strong--ds-text-meta--ds-text-ui--ds-track-meta--ds-weight-display

Notes

THE WRAPPER IS NOT DECORATION

.table-wrap does two jobs nothing else can. It is the scroll container for a table too wide for its column, and it is the containment context .table-stack asks about. A table cannot be its own container β€” container-type on a <table> changes the box's layout in ways table layout is not defined against β€” so the query has to ask something outside it.

A table with no wrapper still renders. It simply cannot scroll and cannot stack, and table-stack on it is a class that does nothing. That is the one silent failure here, which is why the wrapper is in every example on this page.

<div class="table-wrap">
  <table class="table">
    <thead>
      <tr><th scope="col">Release</th><th scope="col" class="table-num">Size</th></tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row" data-label="Release">v1.4.0</th>
        <td class="table-num" data-label="Size">6.1 MB</td>
      </tr>
    </tbody>
  </table>
</div>

BELOW 30REM A TABLE IS A LIST OF RECORDS

This is the one place in the library that reaches for a container query, and it is the case the rule allows: a genuinely two-state layout rather than a range. There is no fluid version of "the columns become rows".

30rem is derived rather than picked. It is two --ds-cell-min tracks β€” the floor this library already publishes for a readable column of content β€” so a table narrower than two of those has stopped being a grid of anything. And it asks the wrapper, never the window: a table in a 300px sidebar on a wide monitor stacks, and the same table in the main column does not.

The cost is data-label on every cell. It duplicates the header text and there is no way around it: CSS cannot read the contents of another element. A cell without one loses its label when the table stacks, silently, which is worth knowing before you leave one off.

THE DIRECTIVE ENHANCES A TABLE THAT IS ALREADY FINISHED

Take the script away and the second specimen is a table: every row there, in the order the author wrote them, with its headers and its scope intact. What goes with the script is the sorting and the filtering β€” and the controls for them go too. data-ds-pending keeps a sort button off the screen until the directive removes it, the same arrangement the theme toggle uses and for the same reason: a control painted before its behaviour exists is a control somebody can press for nothing.

<div x-ds-table>
  <input class="input" type="search" data-ds-search aria-label="Filter the rows" />
  <div class="table-wrap">
    <table class="table table-pin">
      <thead><tr>
        <th scope="col"><button class="table-sort" type="button" data-ds-pending>Release</button></th>
      </tr></thead>
      <tbody>…</tbody>
      <tbody class="table-empty" hidden><tr><td colspan="1">Nothing matched.</td></tr></tbody>
    </table>
  </div>
</div>

THE THIRD CLICK PUTS THE AUTHOR'S ORDER BACK

Ascending, descending, then the order the rows were written in β€” which is frequently the meaningful one, because a changelog is in release order and nothing else, and it is otherwise unrecoverable without a reload. aria-sort says none for that state, which is the value it has for exactly this.

The arrow is drawn from aria-sort and from nothing else. There is no second class saying which column is sorted, so what a screen reader is told and what the arrow shows cannot disagree. And a column that has never been sorted shows no arrow at all β€” an arrow on every header is an interface asking to be read as already sorted.

The sort is stable, so a second column sorted after a first keeps the first's order inside equal values. That has been a requirement of Array.prototype.sort since ES2019, so it is a guarantee rather than a hope.

NUMBERS ARE DETECTED, NOT DECLARED

A column sorts numerically when every cell in it that has any text parses as a number once the units come off, so "6.1 MB" and "12 kB" sort by 6.1 and 12 rather than by "1" coming before "6". Declaring the type on every column would be a second spelling of what the values already say. data-sort="text" on the <th> is there for the column where the guess is wrong.

IT SHIPS NO WORDS, INCLUDING "3 OF 12"

The count of what survived a filter goes out as data-ds-shown and data-ds-total on the root, and as a ds-table:change event carrying the same two numbers. A library that wrote "3 of 12" would be a library with an opinion about English, and this one has none β€” the same argument it makes about fonts.

// The library ships no words, so the count goes out as data.
root.addEventListener('ds-table:change', (e) => {
  status.textContent = `${e.detail.shown} of ${e.detail.total}`;
});

The empty row is the author's too. It is a <tbody class="table-empty" hidden> in the markup, and the directive only toggles the attribute β€” so a table with no script never shows a message about a search that cannot happen.

THE PART NO CSS HERE CAN DO FOR YOU

scope on every header cell. <th scope="col"> at the top of a column and <th scope="row"> at the start of a row is the only thing that tells a screen reader which header belongs to which cell. Without it a table of any width is read as a flat list of values, and nothing on this page pretends otherwise.