Data table
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
| Region | Quarter | Revenue |
|---|---|---|
| North | Q1 | 1,240.00 |
| South | Q1 | 986.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.
| Name | Size |
|---|---|
| report.pdf | 2.4 MB |
| notes.md | 18 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.
| Order | Customer | 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.
| Token | Default |
|---|---|
| --table-border-color | var(--color-border-subtle) |
| --table-border-width | var(--border-width) |
| --table-caption-color | var(--color-on-surface-muted) |
| --table-caption-font-size | var(--font-size-sm) |
| --table-cell-padding-block | var(--space-3) |
| --table-cell-padding-inline | var(--space-4) |
| --table-head-bg | var(--color-surface) |
| --table-head-border-color | var(--color-border) |
| --table-head-border-width | var(--border-width-strong) |
| --table-head-color | var(--color-on-surface) |
| --table-head-font-weight | var(--font-weight-semibold) |
| --table-row-hover-bg | var(--color-surface-sunken) |
| --table-row-stripe-bg | var(--color-surface-sunken) |
| --table-row-stripe-hover-bg | var(--color-surface-raised) |
| --table-scroll-max-block-size | none |
| --table-sort-icon | url("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-size | 0.5em |
| --data-table-bar-gap | var(--space-2) |
| --data-table-count-color | var(--color-on-surface-muted) |
| --data-table-count-font-size | var(--font-size-xs) |
| --data-table-gap | var(--space-3) |
| --table-color | var(--color-on-surface) |
| --table-font-size | var(--font-size-sm) |
| --table-line-height | var(--line-height-snug) |
| --data-table-empty-color | var(--color-on-surface-muted) |
| --data-table-empty-padding | var(--space-8) |
| --data-table-menu-icon | url("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-size | 2.75rem |
| --data-table-sort-active-color | var(--color-on-surface) |
| --data-table-selected-bg | var(--color-surface-sunken) |
| --data-table-sort-color | var(--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
- Wrap the table in
.table-scrolland make that element a tab stop.overflow-x: autoon 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 atabindexfrom CSS. So the container carriestabindex="0". A baretabindex="0"is then an unnamed, roleless stop that announces as "group" or as nothing at all, which is why it also carriesrole="region"andaria-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. scopeon 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.- Name the table. A
<caption>is the best name — it is visible, so everyone gets it.aria-labelon the<table>is the fallback when the surrounding page already provides the heading. .table-stickyneeds a bounded container. Set--table-scroll-max-block-size(or anymax-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..table-numgoes on every cell of the column, header included.<col>accepts onlybackground,border,widthandvisibility, 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.- 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-numis self-contained and safe anywhere; every other modifier here —.table-striped,.table-hover,.table-sticky,.table-sm— belongs on the.tableitself. - 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.
- Put
data-sort-valueon 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. - 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. - Give the scroll container a name and a
tabindex. That istable.css's requirement and it does not go away here.
src/css/components/data-table.css · npx mostlycss add data-table