Data Table
Native tables with responsive overflow, density and border variants, sortable headers, row states, totals, and sticky headers.
An order queue built from real table concerns
Selection, row headers, status, aligned money, timestamps, and actions compose without changing native table semantics.
<data-table striped hover density="compact" tabindex="0"
aria-label="Scrollable order queue"
style="--table-min-width: 42rem">
<table>
<caption>Orders requiring attention</caption>
<thead>
<tr>
<th scope="col"><input type="checkbox" aria-label="Select all orders" /></th>
<th scope="col">Order</th>
<th scope="col">Customer</th>
<th scope="col" data-numeric>Total</th>
<th scope="col" data-actions>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="checkbox" aria-label="Select order 1848" /></td>
<th scope="row">#1848</th>
<td>Northstar Labs</td>
<td data-numeric>$1,284.00</td>
<td data-actions><button aria-label="Open order 1848">⋯</button></td>
</tr>
</tbody>
</table>
</data-table> | Order | Customer | Status | Total | Placed | Actions | |
|---|---|---|---|---|---|---|
| #1848 | Northstar Labs | $1,284.00 | 12 min ago | |||
| #1847 | Juniper Studio | $248.50 | 28 min ago | |||
| #1846 | Atlas Coffee | $96.00 | 41 min ago |
More examples
Each example introduces a different part of the shipped API.
Sortable metrics expose state on the header
The live buttons reorder rows and move aria-sort to the active column; CSS only supplies the full-cell control and state icon.
<th scope="col" aria-sort="descending">
<button type="button" data-table-sort>
Revenue <span data-sort-icon aria-hidden="true"></span>
</button>
</th>
<td data-numeric data-sort-value="184200">$184,200</td> | Region | ||
|---|---|---|
| North America | $184,200 | +12.4% |
| Europe | $152,800 | +8.7% |
| Asia Pacific | $131,600 | +18.2% |
| Latin America | $98,400 | +6.1% |
Footers and numeric alignment make totals scannable
tfoot separates the summary from the body while data-numeric aligns currency with tabular figures.
<tfoot>
<tr>
<th scope="row">Total</th>
<td data-numeric>2,652</td>
<td data-numeric>$127,530</td>
<td data-numeric>+5.6%</td>
</tr>
</tfoot> | Plan | Accounts | MRR | Change |
|---|---|---|---|
| Starter | 1,842 | $27,630 | +4.2% |
| Team | 684 | $68,400 | +7.8% |
| Business | 126 | $31,500 | +2.1% |
| Total | 2,652 | $127,530 | +5.6% |
Grouped headers describe a real comparison
colgroup and rowgroup scopes preserve relationships when one header spans several related metrics.
<thead>
<tr>
<th scope="col" rowspan="2">Team</th>
<th scope="colgroup" colspan="2">Volume</th>
<th scope="colgroup" colspan="2">Quality</th>
</tr>
<tr>
<th scope="col">Received</th>
<th scope="col">Resolved</th>
<th scope="col">CSAT</th>
<th scope="col">SLA</th>
</tr>
</thead> | Team | Volume | Quality | ||
|---|---|---|---|---|
| Received | Resolved | CSAT | SLA | |
| Americas | 1,284 | 1,196 | 94% | 88% |
| Europe | 968 | 941 | 96% | 92% |
| Asia Pacific | 742 | 704 | 93% | 86% |
Sticky headers belong to a constrained scroll region
The wrapper owns both axes of overflow while native header cells stay visible during a long inventory review.
<data-table sticky-header tabindex="0"
aria-label="Scrollable low-stock inventory"
style="--table-max-block-size: 18rem; --table-min-width: 42rem">
<table>...</table>
</data-table> | SKU | Product | On hand | Reorder at | Supplier |
|---|---|---|---|---|
| ST-204 | Studio headphones | 7 | 12 | Signal Co. |
| KB-118 | Compact keyboard | 4 | 10 | North Input |
| LM-042 | Monitor light | 9 | 15 | Luma Works |
| DS-730 | Desk shelf | 2 | 8 | Oakline |
| CM-315 | USB microphone | 6 | 10 | Signal Co. |
| AR-510 | Monitor arm | 3 | 9 | Frame Labs |
Empty results remain part of the table
A spanning message cell preserves the columns and gives filters a clear recovery action instead of replacing the table with unrelated markup.
<tbody>
<tr>
<td colspan="4" data-table-message>
<strong>No matching invoices</strong>
<p>Clear one or more filters to see results.</p>
<button type="button">Clear filters</button>
</td>
</tr>
</tbody> | Invoice | Customer | Due | Total |
|---|---|---|---|
| No matching invoices Clear one or more filters to see results. | |||
CSS Tags enhances native table elements instead of replacing their accessibility
model. Keep <table>, <caption>, <th>, and <td> in the markup; use the
component API for presentation and overflow.
Host model
Use data-table or .data-table directly on a small native table. When a data
set needs horizontal overflow, keyboard scrolling, or a sticky header, put the
native table inside any of the equivalent wrapper hosts:
<data-table tabindex="0" aria-label="Scrollable orders"> <table>...</table></data-table>
<div data-table-scroll tabindex="0" aria-label="Scrollable orders"> <table>...</table></div>
<div class="table-scroll" tabindex="0" aria-label="Scrollable orders"> <table>...</table></div>Each wrapper now styles its direct table automatically. Add tabindex="0" only
when overflow is possible, and provide a label when nearby text does not already
name the region.
Small datasets fit without forced overflow
The default minimum width is now 100%, so a simple table uses its container instead of becoming 36rem wide.
<table data-table="true" class="table-borderless">
<caption>Feature rollout</caption>
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">Audience</th>
<th scope="col" data-numeric="true">Enabled</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">New billing</th>
<td>Team plans</td>
<td data-numeric="true">82%</td>
</tr>
<tr>
<th scope="row">Search v2</th>
<td>All accounts</td>
<td data-numeric="true">100%</td>
</tr>
</tbody>
</table> | Feature | Audience | Enabled |
|---|---|---|
| New billing | Team plans | 82% |
| Search v2 | All accounts | 100% |
Responsive overflow
Set --table-min-width only when wrapping would make a wide data set difficult
to scan. The wrapper owns scrolling while the table keeps its native display:
<data-table tabindex="0" aria-label="Scrollable order history" style="--table-min-width: 48rem"> <table>...</table></data-table>The wrapper has a visible focus ring, contained overscroll, and a themeable thin
scrollbar. A direct table[data-table] still receives a small-screen overflow
fallback when a wrapper cannot be added.
Variants
Variants work on either the table or wrapper and have custom-attribute,
data-*, and class forms.
| Purpose | Custom host | Data host | Class host |
|---|---|---|---|
| Alternating rows | striped | data-striped | .table-striped |
| Pointer and focus-within row highlight | hover | data-hover | .table-hover |
| Cell grid and strong outer border | bordered | data-bordered | .table-bordered |
| No outer border or dividers | borderless | data-borderless | .table-borderless |
| Compact density | density="compact" | data-density="compact" | .table-compact |
| Spacious density | density="spacious" | data-density="spacious" | .table-spacious |
| Sticky header wrapper | sticky-header | data-sticky-header | .table-sticky-header |
Hover is opt-in and only activates on devices that actually support hover. Rows containing keyboard focus receive the same treatment.
Cell and row hooks
data-numeric/.table-numericaligns values to the inline end and uses tabular figures.data-actions/.table-actionskeeps a narrow action column aligned to the inline end.data-nowrap/.table-nowrapprotects dates, identifiers, and short status values from wrapping.data-table-messagestyles a spanning empty, loading, or error cell.- A body row containing a checked checkbox or radio receives the selected-row
surface.
data-selectedis available when application state is mirrored in the DOM. <tfoot>receives a stronger separator and summary surface automatically.
Sorting
Put the current sort direction on the header cell, not the button. The button
owns interaction; an aria-hidden icon reflects the header state:
<th scope="col" aria-sort="descending"> <button type="button" data-table-sort> Revenue <span data-sort-icon aria-hidden="true"></span> </button></th>CSS Tags makes that button fill the header cell and supplies distinct unsorted,
ascending, and descending icons. Your application must reorder the rows and
move aria-sort to the active header. The interactive revenue example above
does both. Only one header should carry aria-sort at a time, following the
WAI sortable table pattern.
document.querySelectorAll("table[data-sortable]").forEach((table) => { table.addEventListener("click", (event) => { const button = event.target.closest("button[data-table-sort]"); if (!button) return;
const header = button.closest("th"); const column = header.cellIndex; const direction = header.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; const factor = direction === "ascending" ? 1 : -1; const rows = [...table.tBodies[0].rows]; const value = (row) => row.cells[column].dataset.sortValue ?? row.cells[column].textContent.trim();
table.querySelectorAll("th[aria-sort]").forEach((cell) => cell.removeAttribute("aria-sort")); header.setAttribute("aria-sort", direction); rows.sort((a, b) => value(a).localeCompare(value(b), undefined, { numeric: true }) * factor); table.tBodies[0].append(...rows); });});Sticky headers
Sticky headers require a vertically constrained scroll wrapper. Set its maximum
height with --table-max-block-size:
<data-table sticky-header tabindex="0" aria-label="Scrollable inventory" style="--table-max-block-size: 20rem; --table-min-width: 42rem"> <table>...</table></data-table>Theme tokens
.billing-table { --table-min-width: 44rem; --table-layout: auto; --table-cell-padding-block: 0.75rem; --table-cell-padding-inline: 1rem; --table-heading-bg: var(--table-heading-background); --table-row-hover-bg: var(--table-hover-background); --table-row-selected-bg: var(--table-selected-background); --table-footer-bg: var(--table-footer-background); --table-max-block-size: 24rem;}Additional component tokens include --table-bg, --table-text,
--table-border-width, --table-border-color, --table-divider-width,
--table-divider-color, --table-radius, --table-font-size,
--table-line-height, --table-caption-*, --table-heading-*,
--table-stripe-bg, --table-scrollbar-*, and --table-message-*.
Accessibility checklist
- Use
<caption>when a visible name helps identify the dataset. - Add
scope="col"andscope="row"for simple tables; usecolgroup,rowgroup,id, andheadersfor more complex relationships. - Keep sorting controls as real buttons and update
aria-sortwith the rendered order. - Give selection checkboxes an accessible name that identifies their row.
- Make scroll wrappers keyboard reachable and visibly focused.
- Do not use a table for page layout or replace native table structure with responsive card markup when the content remains tabular.