Data table
Tables with sortable headers, selectable rows, numeric columns, compact density and a sticky header in a scroll region.
On this page
Opt in with data-table on the table. It gets a restyled caption, a row hover color and the hooks below. Sort state lives in aria-sort on the th. Row selection uses the row checkbox as the accessible control, and aria-selected on the tr is only the styling hook (assistive technology mostly ignores it on a plain table).
Load css/, or the whole set from css/pico.extras.min.css. See the overview.
Without data-table only three hooks apply: the aria-sort button, data-type="numeric" on a cell and data-density="compact" on a table. A plain table keeps its core styling.
Sortable, selectable table
Wrap the table in a div with role="region", a name and tabindex="0", so keyboard users can scroll it. Set --pico-data-table-scroll-max-block-size on the wrapper to make it scroll vertically and pin the header. Each row has a checkbox, and the Status column is not sortable, so it has no button and no aria-sort.
aria-sort only describes the state, so sorting needs a little script. It has no dependencies and does not support colspan in a sortable header row. For locale formatted numbers, put the raw value in a data-value attribute on the td and read that instead.
<div role="region" aria-labelledby="orders-caption" tabindex="0"
style="--pico-data-table-scroll-max-block-size: 18rem; --pico-data-table-scroll-padding-block-start: 3rem">
<table data-table style="min-inline-size: 44rem">
<caption id="orders-caption">Orders, last 30 days</caption>
<thead>
<tr>
<th scope="col"><input type="checkbox" aria-label="Select all orders" /></th>
<th scope="col" aria-sort="ascending"><button type="button">Order</button></th>
<th scope="col" aria-sort="none"><button type="button">Customer</button></th>
<th scope="col" aria-sort="none"><button type="button">Plan</button></th>
<th scope="col" aria-sort="none"><button type="button">Date</button></th>
<th scope="col">Status</th>
<th scope="col" aria-sort="none" data-type="numeric"><button type="button">Total</button></th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="checkbox" aria-label="Select order #1048" /></td>
<th scope="row">#1048</th>
<td>Marta Cunha</td>
<td>Pro, annual</td>
<td>2026-10-12</td>
<td>Paid</td>
<td data-type="numeric">2,340.00</td>
</tr>
<tr>
<td><input type="checkbox" aria-label="Select order #1047" /></td>
<th scope="row">#1047</th>
<td>Tiago Neves</td>
<td>Team, monthly</td>
<td>2026-10-11</td>
<td>Pending</td>
<td data-type="numeric">189.00</td>
</tr>
<tr>
<td><input type="checkbox" aria-label="Select order #1046" /></td>
<th scope="row">#1046</th>
<td>Helena Duarte</td>
<td>Starter</td>
<td>2026-10-11</td>
<td>Paid</td>
<td data-type="numeric">29.00</td>
</tr>
<tr aria-selected="true">
<td><input type="checkbox" aria-label="Select order #1045" checked /></td>
<th scope="row">#1045</th>
<td>Bruno Lacerda</td>
<td>Pro, monthly</td>
<td>2026-10-09</td>
<td>Failed</td>
<td data-type="numeric">79.00</td>
</tr>
<tr>
<td><input type="checkbox" aria-label="Select order #1044" /></td>
<th scope="row">#1044</th>
<td>Sofia Barros</td>
<td>Enterprise</td>
<td>2026-10-08</td>
<td>Paid</td>
<td data-type="numeric">12,800.00</td>
</tr>
<tr>
<td><input type="checkbox" aria-label="Select order #1043" /></td>
<th scope="row">#1043</th>
<td>Diogo Pires</td>
<td>Team, annual</td>
<td>2026-10-06</td>
<td>Refunded</td>
<td data-type="numeric">1,890.00</td>
</tr>
</tbody>
</table>
</div>
<script>
document.querySelectorAll("th[aria-sort] > button").forEach((btn) => {
btn.addEventListener("click", () => {
const th = btn.parentElement;
const table = th.closest("table");
const next = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending";
const numeric = th.dataset.type === "numeric";
const col = th.cellIndex;
table
.querySelectorAll(":scope > thead th[aria-sort]")
.forEach((h) => h.setAttribute("aria-sort", "none"));
th.setAttribute("aria-sort", next);
const tbody = table.tBodies[0];
const value = (row) => {
const text = row.cells[col].textContent.trim();
return numeric ? parseFloat(text.replace(/[^\d.-]/g, "")) : text;
};
const sign = next === "ascending" ? 1 : -1;
const rows = [...tbody.rows].sort((a, b) => {
const x = value(a);
const y = value(b);
const c = numeric ? x - y : String(x).localeCompare(String(y), undefined, { numeric: true });
return c * sign;
});
tbody.append(...rows);
});
});
</script>The arrow beside a header label is drawn as text, so it survives forced colors, and it has an empty text alternative because aria-sort already says the state. An idle header shows a double arrow, an ascending one an up arrow and a descending one a down arrow.
aria-sort is read when focus lands on the header. For a live announcement, update a role="status" element with text such as "Sorted by Total, descending" after each sort.
Selection
Toggle aria-selected on the tr and the row checkbox together. The attribute is the source of truth, so CSS and assistive technology agree. A cell that holds only a checkbox shrinks to the checkbox.
function selectRow(row, on) {
row.setAttribute("aria-selected", String(on));
row.querySelector('input[type="checkbox"]').checked = on;
}Numeric columns
data-type="numeric" on a th or td right aligns it (at the end edge, so it flips in right to left) and uses tabular figures. On a sortable header, the arrow goes before the label so the label stays flush with the numbers below it.
Compact density
data-density="compact" on the table tightens the padding and sets the font size to 0.875em.
<div role="region" aria-labelledby="compact-caption" tabindex="0">
<table data-table data-density="compact" style="min-inline-size: 36rem">
<caption id="compact-caption">Orders, compact</caption>
<thead>
<tr>
<th scope="col" aria-sort="ascending"><button type="button">Order</button></th>
<th scope="col" aria-sort="none"><button type="button">Customer</button></th>
<th scope="col">Status</th>
<th scope="col" aria-sort="none" data-type="numeric"><button type="button">Total</button></th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">#1048</th>
<td>Marta Cunha</td>
<td>Paid</td>
<td data-type="numeric">2,340.00</td>
</tr>
<tr>
<th scope="row">#1047</th>
<td>Tiago Neves</td>
<td>Pending</td>
<td data-type="numeric">189.00</td>
</tr>
<tr aria-selected="true">
<th scope="row">#1046</th>
<td>Helena Duarte</td>
<td>Paid</td>
<td data-type="numeric">29.00</td>
</tr>
</tbody>
</table>
</div>Footer row
A tfoot works as in core. Use colspan on its row header.
<tfoot>
<tr>
<th scope="row" colspan="5">Total</th>
<td data-type="numeric">2,186.60</td>
</tr>
</tfoot>Empty tables
When there are no rows, put an empty state in a cell that spans the table, or replace the table with it.
Accessibility
- Give every table a
caption, and the scroll region a name that points at it witharia-labelledby. - The scroll region needs
tabindex="0"so keyboard users can reach the hidden columns. It shows a focus ring. - Sortable headers hold a native
button, so Enter and Space work and the header is in the tab order. - Give each row checkbox a label that names the row, like "Select order #1048".
- Under forced colors a selected row gets a line above and below, because a background color would be dropped.
Customization
--pico-data-table-head-background-color- Header cells in a scroll region.
--pico-data-table-row-hover-background-color- Row under the pointer.
--pico-data-table-row-selected-background-color- Row with aria-selected="true": 20% of the primary color over the page background.
--pico-data-table-scroll-max-block-size- Set on the wrapper to scroll vertically and pin the header.
--pico-data-table-scroll-padding-block-start- Space for the sticky header when scrolling a row into view.
--pico-data-table-compact-padding-block- Cell padding of the compact density, block axis.
--pico-data-table-compact-padding-inline- Cell padding of the compact density, inline axis.
--pico-data-table-sort-idle-opacity- Opacity of the arrow on a header that is not sorted.