# Data table

> Rows of records in a scrolling table with a sticky header, sort state and row selection.

## Anatomy

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](/table.html.md) are enough.

Orders
| 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.

| Customer | Status | Total |
| --- | --- | --- |
| 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.

Search customers

Matching records:

Customers
| 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.

Customers per page 123

Search paged customers

Matching records:

Paged customers
|  | Customer | Status |
| --- | --- | --- |
| Details | Alvin | Shipped |
| Alvin's delivery details stay with the record. |
|  | Alan | Pending |
|  | Jonathan | Delivered |
|  | Private customer | Hidden |

1.  Previous

3.  Next

```
<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.

Monthly visitors
| 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.

Accounts
|  | 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.

| Customer | 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.

Columns

| Customer | Status | Region | Total |
| --- | --- | --- | --- |
| Alan | Pending | eu-west | $3.76 |
| Alvin | Shipped | us-east | $87.00 |
| Jonathan | Shipped | ap-south | $142.50 |

-   Status
-   Total

```
<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 |
