Data table

.data-table[data-ui="data-table"][data-table-page-size="5"]

Table — the surface and the semantics — and data table, which is the same table with sorting, filtering, selection and paging on top of it.

Examples

A plain table

View “A plain table” as
Quarterly revenue by region
Region Quarter Revenue
NorthQ11,240.00
SouthQ1986.50
<div class="table-scroll" tabindex="0" role="region"
     aria-label="Quarterly revenue by region">
  <table class="table table-striped table-hover">
    <caption>Quarterly revenue by region</caption>
    <thead>
      <tr>
        <th scope="col">Region</th>
        <th scope="col" aria-sort="ascending">Quarter</th>
        <th scope="col" class="table-num">Revenue</th>
      </tr>
    </thead>
    <tbody>
      <tr><th scope="row">North</th><td>Q1</td><td class="table-num">1,240.00</td></tr>
      <tr><th scope="row">South</th><td>Q1</td><td class="table-num">986.50</td></tr>
    </tbody>
  </table>
</div>

The wrapper is tabindex="0" with a role="region" and a name.

overflow-x: auto alone leaves a keyboard user unable to reach the columns past the right edge — a WCAG 2.1.1 failure that Firefox quietly patches and the other two engines do not.

Sorting is state, not behaviour

View “Sorting is state, not behaviour” as
Files
Name Size
report.pdf2.4 MB
notes.md18 KB
<div class="table-scroll" tabindex="0" role="region" aria-label="Files">
  <table class="table">
    <caption>Files</caption>
    <thead>
      <tr>
        <th scope="col" aria-sort="descending">Name</th>
        <th scope="col" class="table-num">Size</th>
      </tr>
    </thead>
    <tbody>
      <tr><th scope="row">report.pdf</th><td class="table-num">2.4 MB</td></tr>
      <tr><th scope="row">notes.md</th><td class="table-num">18 KB</td></tr>
    </tbody>
  </table>
</div>

aria-sort is styled, never implemented. Which column a table is sorted by is something the server or the page decides; a component that sorted the DOM for you would be wrong the moment the data was paginated.

Sort, filter, hide columns, select rows, page

View “Sort, filter, hide columns, select rows, page” as

Orders
Order Customer Email Status Placed Total
ORD-3021 Ada Lovelace ada@analytical.eng Shipped 2026-07-14 €1240.00
ORD-3022 Grace Hopper grace@navy.mil Paid 2026-07-15 €986.50
ORD-3023 Alan Turing alan@bletchley.uk Refunded 2026-07-15 €312.00
ORD-3024 Katherine Johnson katherine@nasa.gov Shipped 2026-07-18 €4780.25
ORD-3025 Barbara Liskov barbara@mit.edu Paid 2026-07-21 €58.00
ORD-3026 Radia Perlman radia@spanning.net Pending 2026-07-22 €2019.99
ORD-3027 Margaret Hamilton margaret@apollo.gov Shipped 2026-07-25 €745.10
ORD-3028 Frances Allen frances@ibm.com Paid 2026-08-01 €130.40

<div class="data-table" data-ui="data-table" data-table-page-size="5"
     data-table-empty="No order matches that filter.">

  <div class="data-table-bar">
    <label class="sr-only" for="dt-filter">Filter orders</label>
    <input class="input input-sm data-table-filter" id="dt-filter" type="search"
           placeholder="Filter by anything…" />

    <p class="data-table-count" data-table-count></p>
  </div>

  <div class="table-scroll" tabindex="0" role="region" aria-label="Orders">
    <table class="table table-hover">
      <caption class="sr-only">Orders</caption>
      <thead>
        <tr>
          <th scope="col" class="data-table-pick">
            <input class="check" type="checkbox" data-table-select-all
                   aria-label="Select every visible order" />
          </th>
          <th scope="col" data-table-column="order" data-table-sort="text">Order</th>
          <th scope="col" data-table-column="customer" data-table-sort="text">Customer</th>
          <th scope="col" data-table-column="email" data-table-sort="text">Email</th>
          <th scope="col" data-table-column="status" data-table-sort="text">Status</th>
          <th scope="col" data-table-column="placed" data-table-sort="date">Placed</th>
          <th scope="col" class="table-num" data-table-column="total" data-table-sort="number">Total</th>
        </tr>
      </thead>
      <tbody>
          <tr>
            <td class="data-table-pick">
              <input class="check" type="checkbox" data-table-select
                     aria-label="Select order ORD-3021, Ada Lovelace" />
            </td>
            <th scope="row">ORD-3021</th>
            <td>Ada Lovelace</td>
            <td data-table-cell="email">ada@analytical.eng</td>
            <td><span class="badge badge-success">Shipped</span></td>
            <td data-sort-value="2026-07-14">2026-07-14</td>
            <td class="table-num" data-sort-value="1240">€1240.00</td>
          </tr>
          <tr>
            <td class="data-table-pick">
              <input class="check" type="checkbox" data-table-select
                     aria-label="Select order ORD-3022, Grace Hopper" />
            </td>
            <th scope="row">ORD-3022</th>
            <td>Grace Hopper</td>
            <td data-table-cell="email">grace@navy.mil</td>
            <td><span class="badge badge-info">Paid</span></td>
            <td data-sort-value="2026-07-15">2026-07-15</td>
            <td class="table-num" data-sort-value="986.5">€986.50</td>
          </tr>
          <tr>
            <td class="data-table-pick">
              <input class="check" type="checkbox" data-table-select
                     aria-label="Select order ORD-3023, Alan Turing" />
            </td>
            <th scope="row">ORD-3023</th>
            <td>Alan Turing</td>
            <td data-table-cell="email">alan@bletchley.uk</td>
            <td><span class="badge badge-danger">Refunded</span></td>
            <td data-sort-value="2026-07-15">2026-07-15</td>
            <td class="table-num" data-sort-value="312">€312.00</td>
          </tr>
          <tr>
            <td class="data-table-pick">
              <input class="check" type="checkbox" data-table-select
                     aria-label="Select order ORD-3024, Katherine Johnson" />
            </td>
            <th scope="row">ORD-3024</th>
            <td>Katherine Johnson</td>
            <td data-table-cell="email">katherine@nasa.gov</td>
            <td><span class="badge badge-success">Shipped</span></td>
            <td data-sort-value="2026-07-18">2026-07-18</td>
            <td class="table-num" data-sort-value="4780.25">€4780.25</td>
          </tr>
          <tr>
            <td class="data-table-pick">
              <input class="check" type="checkbox" data-table-select
                     aria-label="Select order ORD-3025, Barbara Liskov" />
            </td>
            <th scope="row">ORD-3025</th>
            <td>Barbara Liskov</td>
            <td data-table-cell="email">barbara@mit.edu</td>
            <td><span class="badge badge-info">Paid</span></td>
            <td data-sort-value="2026-07-21">2026-07-21</td>
            <td class="table-num" data-sort-value="58">€58.00</td>
          </tr>
          <tr>
            <td class="data-table-pick">
              <input class="check" type="checkbox" data-table-select
                     aria-label="Select order ORD-3026, Radia Perlman" />
            </td>
            <th scope="row">ORD-3026</th>
            <td>Radia Perlman</td>
            <td data-table-cell="email">radia@spanning.net</td>
            <td><span class="badge badge-warning">Pending</span></td>
            <td data-sort-value="2026-07-22">2026-07-22</td>
            <td class="table-num" data-sort-value="2019.99">€2019.99</td>
          </tr>
          <tr>
            <td class="data-table-pick">
              <input class="check" type="checkbox" data-table-select
                     aria-label="Select order ORD-3027, Margaret Hamilton" />
            </td>
            <th scope="row">ORD-3027</th>
            <td>Margaret Hamilton</td>
            <td data-table-cell="email">margaret@apollo.gov</td>
            <td><span class="badge badge-success">Shipped</span></td>
            <td data-sort-value="2026-07-25">2026-07-25</td>
            <td class="table-num" data-sort-value="745.1">€745.10</td>
          </tr>
          <tr>
            <td class="data-table-pick">
              <input class="check" type="checkbox" data-table-select
                     aria-label="Select order ORD-3028, Frances Allen" />
            </td>
            <th scope="row">ORD-3028</th>
            <td>Frances Allen</td>
            <td data-table-cell="email">frances@ibm.com</td>
            <td><span class="badge badge-info">Paid</span></td>
            <td data-sort-value="2026-08-01">2026-08-01</td>
            <td class="table-num" data-sort-value="130.4">€130.40</td>
          </tr>
      </tbody>
    </table>
  </div>

  <div class="data-table-bar data-table-bar-end">
    <p class="data-table-count" data-table-range></p>
    <div class="btn-group">
      <button class="btn btn-sm" type="button" data-table-page="prev">Previous</button>
      <button class="btn btn-sm" type="button" data-table-page="next">Next</button>
    </div>
  </div>
</div>

Sorting, filtering, column visibility, row selection and paging, over markup that is still a <table> from top to bottom.

Every row is a <tr>, every header a <th scope>, the sort state is aria-sort on the header cell, and a hidden row carries the hidden attribute rather than being removed — which is the whole reason this exists as a component instead of a <div> grid with role="grid" bolted on. Press a header. Everything that column can do is in one panel — sort it either way, hide it, and bring back a column you hid, since a hidden column has to be reachable from somewhere and the header it belonged to is gone. That replaced a “Columns” button in the bar above the table, which is a second place to look for something that belongs to a column. The trigger is a real <button> the module puts inside each header: a <th> is not focusable and has no role that promises activation, so a click handler on the cell is a control only a pointer can reach. It is an ordinary popover of ordinary buttons and checkboxes — no role="menu", because the panel is built at runtime and a menu role whose arrow keys were never wired is worse than no role at all. data-sort-value on the Total and Placed cells is the one thing the component cannot work out for itself: “€1240.00” sorts before “€986.50” as text.

Tokens 31

Level 2, declared on .data-table itself. Set any of them on that selector to restyle this component without touching the skin.

Level 2 tokens declared by data-table
TokenDefault
--table-border-colorvar(--color-border-subtle)
--table-border-widthvar(--border-width)
--table-caption-colorvar(--color-on-surface-muted)
--table-caption-font-sizevar(--font-size-sm)
--table-cell-padding-blockvar(--space-3)
--table-cell-padding-inlinevar(--space-4)
--table-head-bgvar(--color-surface)
--table-head-border-colorvar(--color-border)
--table-head-border-widthvar(--border-width-strong)
--table-head-colorvar(--color-on-surface)
--table-head-font-weightvar(--font-weight-semibold)
--table-row-hover-bgvar(--color-surface-sunken)
--table-row-stripe-bgvar(--color-surface-sunken)
--table-row-stripe-hover-bgvar(--color-surface-raised)
--table-scroll-max-block-sizenone
--table-sort-iconurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 15 6-6 6 6'/%3E%3C/svg%3E")
--table-sort-indicator-size0.5em
--data-table-bar-gapvar(--space-2)
--data-table-count-colorvar(--color-on-surface-muted)
--data-table-count-font-sizevar(--font-size-xs)
--data-table-gapvar(--space-3)
--table-colorvar(--color-on-surface)
--table-font-sizevar(--font-size-sm)
--table-line-heightvar(--line-height-snug)
--data-table-empty-colorvar(--color-on-surface-muted)
--data-table-empty-paddingvar(--space-8)
--data-table-menu-iconurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")
--data-table-pick-inline-size2.75rem
--data-table-sort-active-colorvar(--color-on-surface)
--data-table-selected-bgvar(--color-surface-sunken)
--data-table-sort-colorvar(--color-on-surface-muted)

Variants and states

Variants

  • .data-table-plain
  • .table-align-top
  • .table-hover
  • .table-sm
  • .table-sticky
  • .table-striped

Inside it

  • .table
  • .data-table-header
  • .data-table-header-mark
  • .data-table-header-label
  • .table-scroll
  • .data-table-bar
  • .data-table-bar-end
  • .data-table-count
  • .data-table-empty
  • .data-table-filter
  • .data-table-menu
  • .data-table-menu-column
  • .data-table-pick
  • .data-table-sort
  • .table-num

State it reads

Read from the platform, never mirrored into a class that could disagree with it.

  • :focus
  • :hover

Before you ship it

This one needs a script. Opt the markup in with data-ui="data-table" and import import 'mostlycss/js/components/data-table' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.

What you have to do 10 requirements

  1. Wrap the table in .table-scroll and make that element a tab stop. overflow-x: auto on its own produces content that can be reached with a mouse or a finger and by nothing else: a keyboard user has no way to scroll a box that cannot take focus, so the columns past the fold are simply unreachable. Chromium made scrollers focusable by default in 2024, Firefox and WebKit have not, and a library cannot ship a tabindex from CSS. So the container carries tabindex="0". A bare tabindex="0" is then an unnamed, roleless stop that announces as "group" or as nothing at all, which is why it also carries role="region" and aria-label. Give it the same text as the <caption>; the caption names the table, the label names the scrollable region around it. The cost is a tab stop that exists even when the table happens to fit. CSS cannot detect overflow, so this is not conditional. It is the smaller failure by a distance.
  2. scope on every header cell. scope="col" in <thead>, scope="row" on a row's leading <th>. Without it a screen reader guesses the association, and guesses wrong on any table with row headers.
  3. Name the table. A <caption> is the best name — it is visible, so everyone gets it. aria-label on the <table> is the fallback when the surrounding page already provides the heading.
  4. .table-sticky needs a bounded container. Set --table-scroll-max-block-size (or any max-block-size) on .table-scroll. Without one the nearest scrollport is the page, and the header sticks to the top of the viewport while the page scrolls — which is a different, usually unwanted, behaviour, and will sit on top of any fixed site header.
  5. .table-num goes on every cell of the column, header included. <col> accepts only background, border, width and visibility, so a <col class="table-num"> silently does nothing. There is no column selector in CSS; there is :nth-child(), which breaks the moment a cell spans. Per-cell is the honest mechanism.
  6. Sub-element classes that mean nothing on their own: .table-scroll (needs a table inside to be worth scrolling) and .table-num (a cell class). .table-num is self-contained and safe anywhere; every other modifier here — .table-striped, .table-hover, .table-sticky, .table-sm — belongs on the .table itself.
  7. Name every checkbox. A column of unnamed checkboxes announces as "checkbox, checkbox, checkbox". The select-all one gets "Select every row"; a row's gets something that identifies that row.
  8. Put data-sort-value on any cell whose text is not its value — money, dates, anything localised. Sorting the rendered string is the single most common defect in a table like this.
  9. Keep the <caption>, even as .sr-only. The filter and the paging change what is in the table under someone who cannot see it, and the caption is what the live count refers back to.
  10. Give the scroll container a name and a tabindex. That is table.css's requirement and it does not go away here.

src/css/components/data-table.css · npx mostlycss add data-table