Wrap one native <table> in a
<div class="data-table">. The wrapper scrolls and
draws the outline, so the table keeps its table semantics.
Add numeric to the header and body cells of a number
column to align them on the end edge with tabular figures. For a
plain table of a few rows, the table
styles are enough.
| Customer | Status | Items | Total |
|---|---|---|---|
| Alvin | Shipped | 3 | $87.00 |
| Alan | Delivered | 1 | $3.76 |
| Jonathan | Pending | 12 | $142.50 |
| Shannon | Shipped | 2 | $9.99 |
<div class="data-table">
<table>
<caption>Orders</caption>
<thead>
<tr>
<th scope="col">Customer</th>
<th scope="col">Status</th>
<th scope="col" class="numeric">Total</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Alvin</th>
<td>Shipped</td>
<td class="numeric">$87.00</td>
</tr>
</tbody>
</table>
</div>
Sorting
Put a <button> in each sortable header cell. Set
aria-sort="ascending" or "descending" on the
header of the sorted column, and the framework draws its arrow. Other
sortable headers show a faint arrow on hover.
Add data-sort to the wrapper to have the framework sort
the rows too. Pressing a header button reorders the rows by that
column and moves aria-sort. A numeric header
compares numbers, so $1,200.00 sorts after $87.00. Other columns
compare text in the page's language, with 9 before 10. A cell's
data-value stands in for its text, and a detail row
moves with its row.
| Alan | Delivered | $3.76 |
|---|---|---|
| Alvin | Shipped | $87.00 |
| Jonathan | Pending | $142.50 |
<thead>
<tr>
<th scope="col" aria-sort="ascending"><button type="button">Customer</button></th>
<th scope="col"><button type="button">Status</button></th>
<th scope="col" class="numeric"><button type="button">Total</button></th>
</tr>
</thead>
<div class="data-table" data-sort>
<table>…</table>
</div>
Each press fires a cancelable expressive:sort from the
wrapper, with { header, column, direction }. A
server-paged table cancels it and fetches the sorted page. By then,
aria-sort has already moved. Without
data-sort, the page handles the header buttons itself.
table.addEventListener('expressive:sort', (event) => {
event.preventDefault();
loadPage({ sort: event.detail.column, direction: event.detail.direction });
});
Row selection
Put a checkbox in a row's first cell. Checking it selects the row and
fills it with secondary-container. Name each checkbox
after its row. aria-selected is not valid on the rows of a
plain table, so the checkbox carries the state.
Put a checkbox in the first header cell to select every enabled
record visible in the current view. Hidden records keep their selections. It
shows mixed while only some rows are selected, and it skips disabled
rows. Every change to the selection fires
expressive:change from the wrapper with
{ selected }, the selected rows. The page can use it
to update a bulk action bar.
| Customer | Total | |
|---|---|---|
| Alvin | $87.00 | |
| Alan | $3.76 | |
| Jonathan | $142.50 |
<tr>
<td><label><input type="checkbox" checked aria-label="Select Alvin"></label></td>
<th scope="row">Alvin</th>
<td class="numeric">$87.00</td>
</tr>
Text filtering
Put a labelled input[type="search"][data-table-filter]
inside the wrapper. Mark each searchable column's header with
data-filter, including headers in grouped rows that span one
column. The trimmed query matches a substring in any marked column,
ignoring case in the table's language. A cell's data-value
replaces its text for search, as it does for sorting.
Details stay with their record, and author-hidden records and details
stay hidden. Clearing the query restores the records without changing
aria-expanded. Select-all changes only enabled visible
records; the selection event still reports all selected records.
The framework reserves data-table-filter-hidden for its
row state. Do not author it.
An optional named output[data-table-filter-count] with
role="status" and aria-live="polite" receives
the matching record count. It excludes details and author-hidden
records, includes disabled records and keeps focus in the search.
Supply the labels and count wording in your page's language.
| Customer | Status |
|---|---|
| Alvin | Shipped |
| Alan | Pending |
| Jonathan | Delivered |
<div class="data-table">
<div class="field">
<input id="customer-query" type="search" data-table-filter>
<label for="customer-query">Search customers</label>
</div>
<span>Matching records: <output data-table-filter-count role="status" aria-live="polite" aria-label="Matching records"></output></span>
<table>
<caption>Customers</caption>
<thead><tr><th scope="col" data-filter>Customer</th><th scope="col">Status</th></tr></thead>
<tbody>
<tr><th scope="row">Alvin</th><td>Shipped</td></tr>
<tr><th scope="row">Alan</th><td>Pending</td></tr>
</tbody>
</table>
</div>
Call Expressive.DataTables.sync(wrapper) after replacing
records, changing the query from script or revealing a hidden table.
Prefilled queries apply on page load. An empty query shows all
author-visible records; a nonempty query with no marked columns matches
none. Detail text never contributes to search.
Each edit fires a bubbling, composed, cancelable
expressive:filter from the wrapper before local filtering.
Its { query } is trimmed but keeps its case, including an
empty string when cleared. For server filtering, cancel synchronously
before awaiting a response. Cancellation leaves the current rows, order,
count and focus unchanged. Filter and sort requests cancel independently.
wrapper.addEventListener('expressive:filter', (event) => {
event.preventDefault();
loadPage({ query: event.detail.query });
});
loadPage is your application code. It handles fetching,
failures and response ordering, replaces records, writes the server
count and calls Expressive.DataTables.sync(wrapper).
Sync never requests a filter or emits selection or sort events.
After cancellation it skips query matching and count writes, restores
only filter-owned hiding and synchronizes selection and columns.
Author-hidden records and their details stay hidden. Sync also works
inside the canceling listener. For tables without accepted server page state, the next uncanceled input
restores local filtering and counts for that wrapper.
Local paging
Set data-page-size="2" on the wrapper to page local records.
Use a positive safe integer; absent or invalid sizes leave records
unpaged. Put named Previous and Next buttons in a named
nav.pagination inside that wrapper. Ordinary pagination
links keep their navigation behavior.
| Status | ||
|---|---|---|
| Alvin | Shipped | |
| Alvin's delivery details stay with the record. | ||
| Alan | Pending | |
| Jonathan | Delivered | |
| Private customer | Hidden | |
<div class="data-table" data-page-size="2">
<label>Customers per page
<select class="browser-default" data-table-page-size>
<option value="1">1</option>
<option value="2" selected>2</option>
<option value="3">3</option>
</select>
</label>
<table>
<caption>Customers</caption>
<thead><tr><th scope="col">Customer</th></tr></thead>
<tbody>
<tr><th scope="row">Alvin</th></tr>
<tr><th scope="row">Alan</th></tr>
<tr><th scope="row">Jonathan</th></tr>
</tbody>
</table>
<nav class="pagination" aria-label="Customer pages">
<ol>
<li><button type="button" data-table-page="previous">Previous</button></li>
<li><output data-table-page-status role="status" aria-live="polite" aria-label="Pages"></output></li>
<li><button type="button" data-table-page="next">Next</button></li>
</ol>
</nav>
</div>
Add a labelled single select[data-table-page-size] to let
users change the size. Its positive safe-integer option value updates
the wrapper's data-page-size, preserving the current page
when available and clamping it otherwise. For seven records on page
four at size two, choosing four shows page two with records five
through seven. Details, selections and matching counts stay intact.
Disabled or multiple controls, unavailable options and invalid values
are ignored. Use browser-default for a native select, or
use the existing FormSelect enhancement. Both work in open shadow roots.
Select the option matching the wrapper's initial size. Changing a
select value from script is silent; dispatch a bubbling, composed
input or change event to apply it. Alternatively,
update the wrapper's size and call Expressive.DataTables.sync(wrapper).
Sync reads the wrapper attribute. Keep the control's scripted value
and presentation aligned yourself; DataTables does not refresh FormSelect.
Filtering and sorting run before paging. Details consume no page slots,
and author hiding and expansion stay unchanged. Filter counts cover all
matching records. Select-all changes only enabled records on the
displayed page; other selections stay checked, and selection events
still report every selected record. The framework reserves
data-table-page-hidden for its page state. Do not author it.
An uncanceled filter input or a local sort returns to the first page.
Canceled requests apply no reset. After changing records or removing
the size, call Expressive.DataTables.sync(wrapper). It
preserves a valid current page, clamps it when needed and emits no
events. Local paging can slice server-filtered DOM records without
changing the application's filter count. It uses DOM record totals.
Page and size controls request expressive:page
before their local default; cancel it to preserve the accepted view.
Page buttons keep focus at boundaries. The framework sets
aria-disabled="true" and ignores unavailable activation,
while keeping the buttons in the Tab order. Page changes emit no filter,
sort or selection event. The optional named status shows localized
numbers such as 1 / 3 in the wrapper's language, or
0 / 0 for no records. Supply its accessible name and
surrounding language. No numbered buttons or page-size selector is
generated. Standalone pagination stays CSS-only.
Server paging
Accept an application-supplied page with
Expressive.DataTables.setPage(wrapper, { page, pageSize, total }).
Use the same native table, page buttons, status and optional size select
shown above. The setter returns true for a valid data-table wrapper with
a direct table and numeric safe integers. Page and pageSize must be
positive; total must be nonnegative. Invalid state returns false without
changing the table. The setter copies state, clamps the page, writes the
wrapper's size and silently synchronizes controls and selection.
const wrapper = document.getElementById('orders');
// Render the response records and matching-count text before accepting state.
Expressive.DataTables.setPage(wrapper, { page: 2, pageSize: 20, total: 87 });
wrapper.addEventListener('expressive:page', event => {
event.preventDefault();
loadPage(event.detail);
});
// After a later response has replaced the records and matching count:
Expressive.DataTables.setPage(wrapper, { page: 3, pageSize: 20, total: 87 });
// Return to local paging from page one, retaining the current size:
Expressive.DataTables.setPage(wrapper, null);
Each valid changed page or size requests a bubbling, composed, cancelable
expressive:page with { page, pageSize }.
Requested pages are one-based and bounded by the known total. An empty
table's size request asks for page one. Boundary or disabled buttons,
unchanged sizes and invalid controls make no request. Native input and
its noncomposed change produce one request; scripted size changes use
one bubbling, composed input or change event.
A server table keeps its accepted state until the application calls the setter again, even when no listener cancels the request. Cancellation also prevents a local table's default. A native select may already show the requested value. The application keeps select or FormSelect presentation aligned with accepted responses. Newer nested requests or setter calls inside a listener supersede an older request's default.
loadPage is application code. It fetches records, handles
errors and response ordering, updates the matching count and accepts
the response state. Build records with safe DOM methods. The framework
never fetches or infers totals from the supplied rows. Every supplied
author-visible record stays displayed, including on page two or when
the response contains more than pageSize records. A zero total shows
0 / 0 without hiding supplied records; render an empty body
when the response is empty.
Filter and sort events remain requests in server mode. They do not filter, reorder or reset the accepted page; sorting still marks its requested direction. Accept page one with the response when appropriate. Sync and the setter emit no events, preserve application counts and keep details, author hiding and selection intact. Select-all affects enabled displayed records. A null reset preserves independent canceled-filter ownership until a later uncanceled filter input restores local filtering.
Bulk actions
Write a .data-table-actions bar just before the
.data-table, and it joins the table's top edge. It takes
the selected color while any row is selected. A leading label, such
as a count, pushes the buttons to the end. The page keeps the count
current and decides which buttons apply. Add hidden to
remove the bar.
<div class="data-table-actions">
<span>2 selected</span>
<button type="button" class="button text">Archive</button>
<button type="button" class="button text">Delete</button>
</div>
<div class="data-table">
<table>…</table>
</div>
Row expansion
Put a tr.data-table-detail right after a row. It shows
only while that row holds a button with
aria-expanded="true". The page flips the attribute, and
the framework shows the row and turns the button's icon. Put the
toggle in a <td>, because the framework draws a
button in a <th> as a sort control. Give the
detail cell a colspan across the table.
<tr>
<th scope="row">Order 1042</th>
<td class="numeric">$87.00</td>
<td>
<button type="button" class="icon-button" aria-expanded="false"
aria-controls="order-1042" aria-label="Details for order 1042">
<span class="material-symbols" aria-hidden="true">expand_more</span>
</button>
</td>
</tr>
<tr id="order-1042" class="data-table-detail">
<td colspan="3">Two items, shipped Monday.</td>
</tr>
toggle.addEventListener('click', () => {
toggle.setAttribute('aria-expanded', String(toggle.getAttribute('aria-expanded') !== 'true'));
});
Scrolling
Give a long table a max-block-size. Once the wrapper
scrolls, the header row sticks to its top. A wrapper that scrolls
must be reachable from the keyboard, so give it
tabindex="0", role="region" and a name,
usually the caption.
| Month | Visitors | Change |
|---|---|---|
| January | 12,480 | +4% |
| February | 11,902 | −5% |
| March | 13,377 | +12% |
| April | 13,015 | −3% |
| May | 14,260 | +10% |
| June | 15,104 | +6% |
<div class="data-table" tabindex="0" role="region" aria-labelledby="visitors-caption"
style="max-block-size: 240px">
<table>
<caption id="visitors-caption">Monthly visitors</caption>
…
</table>
</div>
Sticky first column
Add sticky-column to the wrapper of a wide table to keep
its first column in view while the other columns scroll sideways.
When the first column holds row checkboxes, the name cell after it
stays pinned too, and the checkbox column takes the fixed width of
--md-comp-data-table-checkbox-column-width. Change that
token, not the cell, if you restyle the checkbox column. Pinned cells
keep their row's hover and selected colors, and a divider marks
their edge.
Give each row one cell in the pinned column: a first cell that spans several columns pins its whole width. Detail rows and the caption scroll with the table.
| Account | Plan | Seats | Region | Renews | Owner | |
|---|---|---|---|---|---|---|
| Northwind | Business | 120 | eu-west | 2027-03-01 | Ada Lovelace | |
| Contoso | Enterprise | 860 | us-east | 2026-12-15 | Grace Hopper | |
| Fabrikam | Team | 24 | ap-south | 2027-06-30 | Edsger Dijkstra |
<div class="data-table sticky-column" tabindex="0" role="region" aria-labelledby="accounts-caption">
<table>
<caption id="accounts-caption">Accounts</caption>
<thead>
<tr>
<th scope="col"><label><input type="checkbox" aria-label="Select all accounts"></label></th>
<th scope="col">Account</th>
<th scope="col">Plan</th>
…
</tr>
</thead>
<tbody>
<tr>
<td><label><input type="checkbox" aria-label="Select Northwind"></label></td>
<th scope="row">Northwind</th>
<td>Business</td>
…
</tr>
</tbody>
</table>
</div>
Column resize
End a header cell in the first header row with a
<button type="button" class="drag-handle"> named
after its column, and people can resize that column. They drag the
handle, or focus it and press the arrow toward the column's end edge
to widen it by 16px (64px with Shift) and the other arrow to narrow
it. Home and End jump to the narrowest and widest width, and a click,
Enter or Space steps through narrow, middle and wide, the
single-pointer alternative to dragging. The keys mirror in a
right-to-left page.
A column stays between
--md-comp-data-table-column-min-width and the wrapper's
inner width. The first resize holds every column at its current
width, and the table scrolls sideways once its columns outgrow the
wrapper. The handle is not a sort control, so a header cell can hold
both. Put a handle only in a cell that spans one column, and under
sticky-column leave the checkbox column without one.
Each change fires expressive:resize from the wrapper
with { header, column, width }. The framework stores
nothing: save widths from the event and restore them with
Expressive.DataTables.setWidth(header, px), which fires
no event.
| Status | Total | |
|---|---|---|
| Alan | Pending | $3.76 |
| Alvin | Shipped | $87.00 |
| Jonathan | Shipped | $142.50 |
<div class="data-table" data-sort>
<table>
<thead>
<tr>
<th scope="col"><button type="button">Customer</button><button type="button" class="drag-handle" aria-label="Resize Customer"></button></th>
<th scope="col">Status<button type="button" class="drag-handle" aria-label="Resize Status"></button></th>
<th scope="col" class="numeric">Total</th>
</tr>
</thead>
…
</table>
</div>
Column visibility
Give a menu checkbox item data-column set to the
id of a header cell in the table's first header row,
and the item shows and hides that column. While the item is
unchecked, the header cell and the column's cell in every body and
footer row are hidden, and they leave the accessibility tree. A cell
that spans several columns, such as a detail row's cell, stays. An
item unchecked when the page loads starts its column hidden.
The framework fires no event of its own. To keep the choice across
visits, save it from the menu's expressive:change,
which carries { item, checked }, and render each
item's aria-checked from what you saved. After changing
aria-checked from script, or for a table rendered
later, call Expressive.DataTables.sync(wrapper). Name
only header cells that span one column. Leave the row header column
out of the menu, and under sticky-column the pinned
columns too.
| Customer | Status | Region | Total |
|---|---|---|---|
| Alan | Pending | eu-west | $3.76 |
| Alvin | Shipped | us-east | $87.00 |
| Jonathan | Shipped | ap-south | $142.50 |
<div class="data-table-actions">
<button type="button" class="button text menu-trigger" data-target="order-columns">Columns</button>
</div>
<div class="data-table">
<table>
<thead>
<tr>
<th scope="col">Customer</th>
<th scope="col" id="order-status">Status</th>
<th scope="col" id="order-total" class="numeric">Total</th>
</tr>
</thead>
…
</table>
</div>
<menu id="order-columns">
<li role="menuitemcheckbox" aria-checked="true" data-column="order-status"><button type="button">Status</button></li>
<li role="menuitemcheckbox" aria-checked="false" data-column="order-total"><button type="button">Total</button></li>
</menu>
Dense
Add dense to the wrapper to shorten rows to 40px and the
header to 48px. Use it for tables people scan rather than read.
| Service | Region | Latency |
|---|---|---|
| api | eu-west | 42 ms |
| auth | us-east | 18 ms |
| search | ap-south | 97 ms |
<div class="data-table dense">
<table>…</table>
</div>
Tokens
Override these on the .data-table.
| Token | Default |
|---|---|
--md-comp-data-table-container-color |
--md-sys-color-surface |
--md-comp-data-table-shape |
12px |
--md-comp-data-table-outline-color |
--md-sys-color-outline-variant |
--md-comp-data-table-header-height |
56px; dense: 48px |
--md-comp-data-table-row-height |
52px; dense: 40px |
--md-comp-data-table-cell-padding |
16px |
--md-comp-data-table-selected-container-color |
--md-sys-color-secondary-container |
--md-comp-data-table-detail-container-color |
--md-sys-color-surface-container-low |
--md-comp-data-table-checkbox-column-width |
Cell padding + 18px; used by sticky-column |
--md-comp-data-table-column-min-width |
48px; the narrowest a resized column gets |