Table
A quiet table: small gray headers, row dividers and tabular
numbers. Sort, select, filter, page and hide columns with
table.js.
Numbers, a caption and a total row
Every number in the table uses tabular figures so the digits line
up. Put data-align="end" on money columns and use
tfoot for the total row.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit card | $25.00 |
| INV002 | Pending | PayPal | $15.00 |
| INV003 | Unpaid | Bank transfer | $35.00 |
| INV004 | Paid | PayPal | $145.00 |
| Total | $220.00 | ||
<div class="scroll-area" tabindex="0">
<table class="table">
<caption>Invoices for March 2026</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Status</th>
<th scope="col">Method</th>
<th scope="col" data-align="end">Amount</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">INV001</th><td>Paid</td><td>Credit card</td><td data-align="end">$25.00</td></tr>
<tr><th scope="row">INV002</th><td>Pending</td><td>PayPal</td><td data-align="end">$15.00</td></tr>
<tr><th scope="row">INV003</th><td>Unpaid</td><td>Bank transfer</td><td data-align="end">$35.00</td></tr>
<tr><th scope="row">INV004</th><td>Paid</td><td>PayPal</td><td data-align="end">$145.00</td></tr>
</tbody>
<tfoot>
<tr><th scope="row" colspan="3">Total</th><td data-align="end">$220.00</td></tr>
</tfoot>
</table>
</div>
Sortable columns
Add aria-sort to the th and a
button inside it. A click toggles ascending and
descending. Numbers, dates and text each sort by their own type (a
data-sort-value on a cell is used in place of its
text, such as an ISO date). For server-side sorting, use an
a instead of a button.
| Status | ||||
|---|---|---|---|---|
| Timeless Watch | Mar 2, 2026 | Public | 1,204 | $149.00 |
| Crew tee | Aug 15, 2026 | Public | 312 | $24.00 |
| Magic Pen | Nov 30, 2025 | Private | 48 | $9.00 |
| Canvas bag | Jan 9, 2026 | Public | - | $38.00 |
<div class="scroll-area" tabindex="0">
<table class="table" id="sort-demo">
<thead>
<tr>
<th scope="col" aria-sort="none"><button type="button">Product</button></th>
<th scope="col" aria-sort="none"><button type="button">Added</button></th>
<th scope="col">Status</th>
<th scope="col" data-align="end" aria-sort="descending"><button type="button">Sold</button></th>
<th scope="col" data-align="end" aria-sort="none"><button type="button">Price</button></th>
</tr>
</thead>
<tbody>
<tr><td>Timeless Watch</td><td data-sort-value="2026-03-02">Mar 2, 2026</td><td><span class="badge" data-variant="ok">Public</span></td><td data-align="end">1,204</td><td data-align="end">$149.00</td></tr>
<tr><td>Crew tee</td><td data-sort-value="2026-08-15">Aug 15, 2026</td><td><span class="badge" data-variant="ok">Public</span></td><td data-align="end">312</td><td data-align="end">$24.00</td></tr>
<tr><td>Magic Pen</td><td data-sort-value="2025-11-30">Nov 30, 2025</td><td><span class="badge">Private</span></td><td data-align="end">48</td><td data-align="end">$9.00</td></tr>
<tr><td>Canvas bag</td><td data-sort-value="2026-01-09">Jan 9, 2026</td><td><span class="badge" data-variant="ok">Public</span></td><td data-align="end">-</td><td data-align="end">$38.00</td></tr>
</tbody>
</table>
</div>
A picture and a link first, an action last
Make an .avatar or img the first child
of the cell, followed by a link. Align the action right with
data-align="end".
| Customer | Orders | ||
|---|---|---|---|
| EWEmma Wilson | emma@example.com | 12 | View |
| OBOlivia Brown | olivia@example.com | 4 | View |
| JCJames Carter | james@example.com | 1 | View |
<div class="scroll-area" tabindex="0">
<table class="table">
<thead>
<tr>
<th scope="col">Customer</th>
<th scope="col">Email</th>
<th scope="col" data-align="end">Orders</th>
<th scope="col" data-align="end" aria-label="Actions"></th>
</tr>
</thead>
<tbody>
<tr><td><span class="avatar">EW</span><a href="#">Emma Wilson</a></td><td>emma@example.com</td><td data-align="end">12</td><td data-align="end"><a href="#" class="btn" data-size="sm">View</a></td></tr>
<tr><td><span class="avatar">OB</span><a href="#">Olivia Brown</a></td><td>olivia@example.com</td><td data-align="end">4</td><td data-align="end"><a href="#" class="btn" data-size="sm">View</a></td></tr>
<tr><td><span class="avatar">JC</span><a href="#">James Carter</a></td><td>james@example.com</td><td data-align="end">1</td><td data-align="end"><a href="#" class="btn" data-size="sm">View</a></td></tr>
</tbody>
</table>
</div>
Truncate a long name with data-truncate
A cell with data-truncate takes the remaining space
and cuts its text off with … (never narrower than 8rem). Put the
full text in title.
| Webhook | Status | |
|---|---|---|
| Order sync | Active | View |
| Abandoned cart emails for the year-end campaign, second version | Inactive | View |
<div class="scroll-area" tabindex="0" style="max-width: 28rem">
<table class="table">
<thead>
<tr>
<th scope="col">Webhook</th>
<th scope="col">Status</th>
<th scope="col" data-align="end" aria-label="Actions"></th>
</tr>
</thead>
<tbody>
<tr><td data-truncate><a href="#" title="Order sync">Order sync</a></td><td><span class="badge" data-variant="ok">Active</span></td><td data-align="end"><a href="#" class="btn" data-size="sm">View</a></td></tr>
<tr><td data-truncate><a href="#" title="Abandoned cart emails for the year-end campaign, second version">Abandoned cart emails for the year-end campaign, second version</a></td><td><span class="badge">Inactive</span></td><td data-align="end"><a href="#" class="btn" data-size="sm">View</a></td></tr>
</tbody>
</table>
</div>
Selectable rows
A checkbox goes in the first cell of each row, and the one in
thead selects all. Selected rows get a gray fill
(this works without JS). JS sets aria-selected, makes
the select-all checkbox indeterminate and fires
tsunami:select.
| Order | Customer | Total | |
|---|---|---|---|
| #110963 | Emma Wilson | $79.00 | |
| #110962 | Olivia Brown | $26.00 | |
| #110961 | James Carter | $364.50 |
1 selected
<div class="stack" data-gap="sm">
<div class="scroll-area" tabindex="0">
<table class="table" id="select-demo">
<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-align="end">Total</th>
</tr>
</thead>
<tbody>
<tr><td><input type="checkbox" aria-label="Select order 110963" checked /></td><td>#110963</td><td>Emma Wilson</td><td data-align="end">$79.00</td></tr>
<tr><td><input type="checkbox" aria-label="Select order 110962" /></td><td>#110962</td><td>Olivia Brown</td><td data-align="end">$26.00</td></tr>
<tr><td><input type="checkbox" aria-label="Select order 110961" /></td><td>#110961</td><td>James Carter</td><td data-align="end">$364.50</td></tr>
</tbody>
</table>
</div>
<p id="select-count" aria-live="polite">1 selected</p>
<script>
document.getElementById("select-demo").addEventListener("tsunami:select", (e) => {
document.getElementById("select-count").textContent = `${e.detail.count} selected`;
});
</script>
</div>
In a card, edge to edge
A table that is a direct child of a .card (or inside
a .scroll-area) runs to the card's edges. The first
and last cells line up with the card padding.
| Order | Customer | Status | Payment | Total |
|---|---|---|---|---|
| #110963 | Emma Wilson | Paid | Visa •••• 4417 | $79.00 |
| #110962 | Olivia Brown | Pending | PayPal | $26.00 |
| #110961 | James Carter | Paid | Mastercard •••• 8034 | $364.50 |
| #110960 | Mark Jensen | Refunded | Apple Pay | $13.50 |
<div class="card">
<div class="scroll-area" tabindex="0">
<table class="table">
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Customer</th>
<th scope="col">Status</th>
<th scope="col">Payment</th>
<th scope="col" data-align="end">Total</th>
</tr>
</thead>
<tbody>
<tr><td>#110963</td><td>Emma Wilson</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Visa •••• 4417</td><td data-align="end">$79.00</td></tr>
<tr><td>#110962</td><td>Olivia Brown</td><td><span class="badge" data-variant="warning">Pending</span></td><td>PayPal</td><td data-align="end">$26.00</td></tr>
<tr><td>#110961</td><td>James Carter</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Mastercard •••• 8034</td><td data-align="end">$364.50</td></tr>
<tr><td>#110960</td><td>Mark Jensen</td><td><span class="badge" data-variant="danger">Refunded</span></td><td>Apple Pay</td><td data-align="end">$13.50</td></tr>
</tbody>
</table>
</div>
</div>
Pages
Put data-page-size on the table and the script shows
that many rows at a time. A nav.pagination with
data-pagination-for="table-id" is filled with buttons
(with no value, the one in the same card is used).
data-table-info shows the range and a
select with data-table-page-size changes
the size. Rows are hidden, never re-created, so it stays quick up
to a couple of thousand rows.
| #110963 | Liam Nguyen | Pending | Bank transfer | Mar 28, 2026 | $90.00 |
| #110962 | Lucas Garcia | Paid | Apple Pay | Mar 27, 2026 | $178.50 |
| #110961 | Mark Moore | Paid | Visa •••• 4417 | Mar 26, 2026 | $285.25 |
| #110960 | James Lopez | Paid | Mastercard •••• 8034 | Mar 25, 2026 | $318.75 |
| #110959 | Ethan Miller | Pending | PayPal | Mar 24, 2026 | $308.25 |
| #110958 | Zoe Miller | Pending | PayPal | Mar 23, 2026 | $58.25 |
| #110957 | Zoe Brown | Paid | Visa •••• 2290 | Mar 22, 2026 | $325.25 |
| #110956 | Mark Brown | Paid | Visa •••• 4417 | Mar 21, 2026 | $152.50 |
| #110955 | Mark Clark | Refunded | Bank transfer | Mar 20, 2026 | $306.00 |
| #110954 | James Jensen | Refunded | Visa •••• 4417 | Mar 19, 2026 | $29.75 |
| #110953 | Grace Lopez | Paid | Bank transfer | Mar 18, 2026 | $319.25 |
| #110952 | Zoe Moore | Paid | PayPal | Mar 17, 2026 | $316.00 |
| #110951 | Zoe Wilson | Pending | Bank transfer | Mar 16, 2026 | $258.75 |
| #110950 | Zoe Garcia | Pending | Apple Pay | Mar 15, 2026 | $27.25 |
| #110949 | Henry Moore | Paid | Bank transfer | Mar 14, 2026 | $173.75 |
| #110948 | Noah Jensen | Refunded | PayPal | Mar 13, 2026 | $156.25 |
| #110947 | Henry Moore | Paid | Bank transfer | Mar 12, 2026 | $277.00 |
| #110946 | Lucas Wilson | Pending | Mastercard •••• 8034 | Mar 11, 2026 | $68.25 |
| #110945 | James Clark | Pending | PayPal | Mar 10, 2026 | $238.00 |
| #110944 | Lucas Miller | Paid | Visa •••• 2290 | Mar 9, 2026 | $246.25 |
| #110943 | Emma Khan | Paid | Bank transfer | Mar 8, 2026 | $60.25 |
| #110942 | Chloe Jensen | Paid | Visa •••• 2290 | Mar 7, 2026 | $88.25 |
| #110941 | Ethan Miller | Pending | Visa •••• 2290 | Mar 6, 2026 | $306.00 |
| #110940 | Liam Miller | Pending | Mastercard •••• 8034 | Mar 5, 2026 | $252.00 |
| #110939 | Sofia Brown | Paid | Mastercard •••• 8034 | Mar 4, 2026 | $21.25 |
<div class="card">
<div class="scroll-area" tabindex="0">
<table class="table" style="min-width: 60rem" id="pg-demo" data-page-size="5">
<thead>
<tr>
<th scope="col" aria-sort="none"><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">Status</button></th>
<th scope="col" aria-sort="none"><button type="button">Payment</button></th>
<th scope="col" aria-sort="none"><button type="button">Date</button></th>
<th scope="col" data-align="end" aria-sort="none"><button type="button">Total</button></th>
</tr>
</thead>
<tbody>
<tr><td>#110963</td><td>Liam Nguyen</td><td><span class="badge" data-variant="warning">Pending</span></td><td>Bank transfer</td><td>Mar 28, 2026</td><td data-align="end">$90.00</td></tr>
<tr><td>#110962</td><td>Lucas Garcia</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Apple Pay</td><td>Mar 27, 2026</td><td data-align="end">$178.50</td></tr>
<tr><td>#110961</td><td>Mark Moore</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Visa •••• 4417</td><td>Mar 26, 2026</td><td data-align="end">$285.25</td></tr>
<!-- more rows -->
</tbody>
<template data-table-empty><p>No orders match your filters.</p></template>
</table>
</div>
<footer>
<p data-table-info="pg-demo" aria-live="polite"></p>
<select class="select" data-size="sm" aria-label="Rows per page" data-table-page-size="pg-demo"><option>5</option><option>10</option><option>25</option></select>
<nav class="pagination" data-size="sm" aria-label="Pages" data-pagination-for="pg-demo"></nav>
</footer>
</div>
Search
An input[data-table-search="table-id"] keeps the rows
that contain the text in any cell, ignoring case. It waits 150 ms
after typing. When nothing matches, the content of
template[data-table-empty] is shown in one cell that
spans the visible columns.
| #110963 | Liam Nguyen | Pending | Bank transfer | Mar 28, 2026 | $90.00 |
| #110962 | Lucas Garcia | Paid | Apple Pay | Mar 27, 2026 | $178.50 |
| #110961 | Mark Moore | Paid | Visa •••• 4417 | Mar 26, 2026 | $285.25 |
| #110960 | James Lopez | Paid | Mastercard •••• 8034 | Mar 25, 2026 | $318.75 |
| #110959 | Ethan Miller | Pending | PayPal | Mar 24, 2026 | $308.25 |
| #110958 | Zoe Miller | Pending | PayPal | Mar 23, 2026 | $58.25 |
| #110957 | Zoe Brown | Paid | Visa •••• 2290 | Mar 22, 2026 | $325.25 |
| #110956 | Mark Brown | Paid | Visa •••• 4417 | Mar 21, 2026 | $152.50 |
| #110955 | Mark Clark | Refunded | Bank transfer | Mar 20, 2026 | $306.00 |
| #110954 | James Jensen | Refunded | Visa •••• 4417 | Mar 19, 2026 | $29.75 |
| #110953 | Grace Lopez | Paid | Bank transfer | Mar 18, 2026 | $319.25 |
| #110952 | Zoe Moore | Paid | PayPal | Mar 17, 2026 | $316.00 |
<div class="input-group"><span><i class="icon icon-search" aria-hidden="true"></i></span><input type="search" placeholder="Search orders" aria-label="Search orders" data-table-search="search-demo" /></div>
<div class="card">
<div class="scroll-area" tabindex="0">
<table class="table" style="min-width: 60rem" id="search-demo">
<thead>
<tr>
<th scope="col" aria-sort="none"><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">Status</button></th>
<th scope="col" aria-sort="none"><button type="button">Payment</button></th>
<th scope="col" aria-sort="none"><button type="button">Date</button></th>
<th scope="col" data-align="end" aria-sort="none"><button type="button">Total</button></th>
</tr>
</thead>
<tbody>
<tr><td>#110963</td><td>Liam Nguyen</td><td><span class="badge" data-variant="warning">Pending</span></td><td>Bank transfer</td><td>Mar 28, 2026</td><td data-align="end">$90.00</td></tr>
<tr><td>#110962</td><td>Lucas Garcia</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Apple Pay</td><td>Mar 27, 2026</td><td data-align="end">$178.50</td></tr>
<tr><td>#110961</td><td>Mark Moore</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Visa •••• 4417</td><td>Mar 26, 2026</td><td data-align="end">$285.25</td></tr>
<!-- more rows -->
</tbody>
<template data-table-empty><p>No orders match your filters.</p></template>
</table>
</div>
</div>
Column filters
A second header row, tr[data-filters], can hold an
input or a select under any column; the cell must sit at that
column's index. An input keeps rows whose cell contains the text
and a select keeps rows whose cell equals the chosen option (the
empty option clears it). Filters combine with the search and send
the table back to page 1.
| #110963 | Liam Nguyen | Pending | Bank transfer | Mar 28, 2026 | $90.00 |
| #110962 | Lucas Garcia | Paid | Apple Pay | Mar 27, 2026 | $178.50 |
| #110961 | Mark Moore | Paid | Visa •••• 4417 | Mar 26, 2026 | $285.25 |
| #110960 | James Lopez | Paid | Mastercard •••• 8034 | Mar 25, 2026 | $318.75 |
| #110959 | Ethan Miller | Pending | PayPal | Mar 24, 2026 | $308.25 |
| #110958 | Zoe Miller | Pending | PayPal | Mar 23, 2026 | $58.25 |
| #110957 | Zoe Brown | Paid | Visa •••• 2290 | Mar 22, 2026 | $325.25 |
| #110956 | Mark Brown | Paid | Visa •••• 4417 | Mar 21, 2026 | $152.50 |
| #110955 | Mark Clark | Refunded | Bank transfer | Mar 20, 2026 | $306.00 |
| #110954 | James Jensen | Refunded | Visa •••• 4417 | Mar 19, 2026 | $29.75 |
| #110953 | Grace Lopez | Paid | Bank transfer | Mar 18, 2026 | $319.25 |
| #110952 | Zoe Moore | Paid | PayPal | Mar 17, 2026 | $316.00 |
<div class="card">
<div class="scroll-area" tabindex="0">
<table class="table" style="min-width: 60rem" id="filter-demo">
<thead>
<tr>
<th scope="col" aria-sort="none"><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">Status</button></th>
<th scope="col" aria-sort="none"><button type="button">Payment</button></th>
<th scope="col" aria-sort="none"><button type="button">Date</button></th>
<th scope="col" data-align="end" aria-sort="none"><button type="button">Total</button></th>
</tr>
<tr data-filters>
<th><input class="input" data-size="sm" type="search" aria-label="Filter by order" placeholder="Filter" /></th>
<th><input class="input" data-size="sm" type="search" aria-label="Filter by customer" placeholder="Filter" /></th>
<th><select class="select" data-size="sm" aria-label="Filter by status"><option value="">All</option><option>Paid</option><option>Pending</option><option>Refunded</option></select></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td>#110963</td><td>Liam Nguyen</td><td><span class="badge" data-variant="warning">Pending</span></td><td>Bank transfer</td><td>Mar 28, 2026</td><td data-align="end">$90.00</td></tr>
<tr><td>#110962</td><td>Lucas Garcia</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Apple Pay</td><td>Mar 27, 2026</td><td data-align="end">$178.50</td></tr>
<tr><td>#110961</td><td>Mark Moore</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Visa •••• 4417</td><td>Mar 26, 2026</td><td data-align="end">$285.25</td></tr>
<!-- more rows -->
</tbody>
<template data-table-empty><p>No orders match your filters.</p></template>
</table>
</div>
</div>
Column visibility
A .dropdown with
data-table-columns="table-id" lists the columns. If
it has no checkboxes the script writes one per header (value =
column index, label = header text); you can also write them
yourself. An unchecked column gets hidden on its
th and td, and the last visible column
cannot be hidden. Add data-table-remember to a table
with an id to keep the choice in
localStorage.
Orders
| #110963 | Liam Nguyen | Pending | Bank transfer | Mar 28, 2026 | $90.00 |
| #110962 | Lucas Garcia | Paid | Apple Pay | Mar 27, 2026 | $178.50 |
| #110961 | Mark Moore | Paid | Visa •••• 4417 | Mar 26, 2026 | $285.25 |
| #110960 | James Lopez | Paid | Mastercard •••• 8034 | Mar 25, 2026 | $318.75 |
| #110959 | Ethan Miller | Pending | PayPal | Mar 24, 2026 | $308.25 |
| #110958 | Zoe Miller | Pending | PayPal | Mar 23, 2026 | $58.25 |
| #110957 | Zoe Brown | Paid | Visa •••• 2290 | Mar 22, 2026 | $325.25 |
| #110956 | Mark Brown | Paid | Visa •••• 4417 | Mar 21, 2026 | $152.50 |
| #110955 | Mark Clark | Refunded | Bank transfer | Mar 20, 2026 | $306.00 |
| #110954 | James Jensen | Refunded | Visa •••• 4417 | Mar 19, 2026 | $29.75 |
<div class="card">
<header>
<h2>Orders</h2>
<aside>
<button type="button" class="btn" data-size="sm" popovertarget="cols-menu"><i class="icon icon-columns-3" aria-hidden="true"></i>Columns</button>
<!-- Empty: a checkbox per header is added for you -->
<div class="dropdown" id="cols-menu" data-table-columns="cols-demo" popover></div>
</aside>
</header>
<div class="scroll-area" tabindex="0">
<table class="table" style="min-width: 60rem" id="cols-demo" data-table-remember>
<thead>
<tr>
<th scope="col" aria-sort="none"><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">Status</button></th>
<th scope="col" aria-sort="none"><button type="button">Payment</button></th>
<th scope="col" aria-sort="none"><button type="button">Date</button></th>
<th scope="col" data-align="end" aria-sort="none"><button type="button">Total</button></th>
</tr>
</thead>
<tbody>
<tr><td>#110963</td><td>Liam Nguyen</td><td><span class="badge" data-variant="warning">Pending</span></td><td>Bank transfer</td><td>Mar 28, 2026</td><td data-align="end">$90.00</td></tr>
<tr><td>#110962</td><td>Lucas Garcia</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Apple Pay</td><td>Mar 27, 2026</td><td data-align="end">$178.50</td></tr>
<tr><td>#110961</td><td>Mark Moore</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Visa •••• 4417</td><td>Mar 26, 2026</td><td data-align="end">$285.25</td></tr>
<!-- more rows -->
</tbody>
<template data-table-empty><p>No orders match your filters.</p></template>
</table>
</div>
</div>
Sticky header
data-sticky keeps the header (and the filter row) at
the top of its scroll container: a .scroll-area with
a max-height, or the page.
data-sticky="both" also keeps the first column at the
left edge when the table scrolls sideways.
| #110963 | Liam Nguyen | Pending | Bank transfer | Mar 28, 2026 | $90.00 |
| #110962 | Lucas Garcia | Paid | Apple Pay | Mar 27, 2026 | $178.50 |
| #110961 | Mark Moore | Paid | Visa •••• 4417 | Mar 26, 2026 | $285.25 |
| #110960 | James Lopez | Paid | Mastercard •••• 8034 | Mar 25, 2026 | $318.75 |
| #110959 | Ethan Miller | Pending | PayPal | Mar 24, 2026 | $308.25 |
| #110958 | Zoe Miller | Pending | PayPal | Mar 23, 2026 | $58.25 |
| #110957 | Zoe Brown | Paid | Visa •••• 2290 | Mar 22, 2026 | $325.25 |
| #110956 | Mark Brown | Paid | Visa •••• 4417 | Mar 21, 2026 | $152.50 |
| #110955 | Mark Clark | Refunded | Bank transfer | Mar 20, 2026 | $306.00 |
| #110954 | James Jensen | Refunded | Visa •••• 4417 | Mar 19, 2026 | $29.75 |
| #110953 | Grace Lopez | Paid | Bank transfer | Mar 18, 2026 | $319.25 |
| #110952 | Zoe Moore | Paid | PayPal | Mar 17, 2026 | $316.00 |
| #110951 | Zoe Wilson | Pending | Bank transfer | Mar 16, 2026 | $258.75 |
| #110950 | Zoe Garcia | Pending | Apple Pay | Mar 15, 2026 | $27.25 |
| #110949 | Henry Moore | Paid | Bank transfer | Mar 14, 2026 | $173.75 |
| #110948 | Noah Jensen | Refunded | PayPal | Mar 13, 2026 | $156.25 |
| #110947 | Henry Moore | Paid | Bank transfer | Mar 12, 2026 | $277.00 |
| #110946 | Lucas Wilson | Pending | Mastercard •••• 8034 | Mar 11, 2026 | $68.25 |
| #110945 | James Clark | Pending | PayPal | Mar 10, 2026 | $238.00 |
| #110944 | Lucas Miller | Paid | Visa •••• 2290 | Mar 9, 2026 | $246.25 |
| #110943 | Emma Khan | Paid | Bank transfer | Mar 8, 2026 | $60.25 |
| #110942 | Chloe Jensen | Paid | Visa •••• 2290 | Mar 7, 2026 | $88.25 |
| #110941 | Ethan Miller | Pending | Visa •••• 2290 | Mar 6, 2026 | $306.00 |
| #110940 | Liam Miller | Pending | Mastercard •••• 8034 | Mar 5, 2026 | $252.00 |
| #110939 | Sofia Brown | Paid | Mastercard •••• 8034 | Mar 4, 2026 | $21.25 |
| #110938 | Ethan Miller | Paid | Mastercard •••• 8034 | Mar 3, 2026 | $343.75 |
| #110937 | Sofia Clark | Refunded | Apple Pay | Feb 28, 2026 | $89.75 |
| #110936 | Grace Jensen | Paid | PayPal | Feb 27, 2026 | $12.50 |
| #110935 | Daniel Jensen | Paid | Apple Pay | Feb 26, 2026 | $161.75 |
| #110934 | Mark Patel | Refunded | Visa •••• 2290 | Feb 25, 2026 | $17.00 |
<div class="card">
<div class="scroll-area" tabindex="0" style="max-height: 16rem">
<table class="table" style="min-width: 60rem" id="sticky-demo" data-sticky>
<thead>
<tr>
<th scope="col" aria-sort="none"><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">Status</button></th>
<th scope="col" aria-sort="none"><button type="button">Payment</button></th>
<th scope="col" aria-sort="none"><button type="button">Date</button></th>
<th scope="col" data-align="end" aria-sort="none"><button type="button">Total</button></th>
</tr>
</thead>
<tbody>
<tr><td>#110963</td><td>Liam Nguyen</td><td><span class="badge" data-variant="warning">Pending</span></td><td>Bank transfer</td><td>Mar 28, 2026</td><td data-align="end">$90.00</td></tr>
<tr><td>#110962</td><td>Lucas Garcia</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Apple Pay</td><td>Mar 27, 2026</td><td data-align="end">$178.50</td></tr>
<tr><td>#110961</td><td>Mark Moore</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Visa •••• 4417</td><td>Mar 26, 2026</td><td data-align="end">$285.25</td></tr>
<!-- more rows -->
</tbody>
</table>
</div>
</div>
Everything together
Sorting works on the whole filtered set before it is cut into
pages. The select-all box selects every row that matches the
filter, on all pages, not only the visible one. After any change
the table fires tsunami:tablechange.
Orders
| #110963 | Liam Nguyen | Pending | Bank transfer | Mar 28, 2026 | $90.00 | |
| #110962 | Lucas Garcia | Paid | Apple Pay | Mar 27, 2026 | $178.50 | |
| #110961 | Mark Moore | Paid | Visa •••• 4417 | Mar 26, 2026 | $285.25 | |
| #110960 | James Lopez | Paid | Mastercard •••• 8034 | Mar 25, 2026 | $318.75 | |
| #110959 | Ethan Miller | Pending | PayPal | Mar 24, 2026 | $308.25 | |
| #110958 | Zoe Miller | Pending | PayPal | Mar 23, 2026 | $58.25 | |
| #110957 | Zoe Brown | Paid | Visa •••• 2290 | Mar 22, 2026 | $325.25 | |
| #110956 | Mark Brown | Paid | Visa •••• 4417 | Mar 21, 2026 | $152.50 | |
| #110955 | Mark Clark | Refunded | Bank transfer | Mar 20, 2026 | $306.00 | |
| #110954 | James Jensen | Refunded | Visa •••• 4417 | Mar 19, 2026 | $29.75 | |
| #110953 | Grace Lopez | Paid | Bank transfer | Mar 18, 2026 | $319.25 | |
| #110952 | Zoe Moore | Paid | PayPal | Mar 17, 2026 | $316.00 | |
| #110951 | Zoe Wilson | Pending | Bank transfer | Mar 16, 2026 | $258.75 | |
| #110950 | Zoe Garcia | Pending | Apple Pay | Mar 15, 2026 | $27.25 | |
| #110949 | Henry Moore | Paid | Bank transfer | Mar 14, 2026 | $173.75 | |
| #110948 | Noah Jensen | Refunded | PayPal | Mar 13, 2026 | $156.25 | |
| #110947 | Henry Moore | Paid | Bank transfer | Mar 12, 2026 | $277.00 | |
| #110946 | Lucas Wilson | Pending | Mastercard •••• 8034 | Mar 11, 2026 | $68.25 | |
| #110945 | James Clark | Pending | PayPal | Mar 10, 2026 | $238.00 | |
| #110944 | Lucas Miller | Paid | Visa •••• 2290 | Mar 9, 2026 | $246.25 | |
| #110943 | Emma Khan | Paid | Bank transfer | Mar 8, 2026 | $60.25 | |
| #110942 | Chloe Jensen | Paid | Visa •••• 2290 | Mar 7, 2026 | $88.25 | |
| #110941 | Ethan Miller | Pending | Visa •••• 2290 | Mar 6, 2026 | $306.00 | |
| #110940 | Liam Miller | Pending | Mastercard •••• 8034 | Mar 5, 2026 | $252.00 | |
| #110939 | Sofia Brown | Paid | Mastercard •••• 8034 | Mar 4, 2026 | $21.25 | |
| #110938 | Ethan Miller | Paid | Mastercard •••• 8034 | Mar 3, 2026 | $343.75 | |
| #110937 | Sofia Clark | Refunded | Apple Pay | Feb 28, 2026 | $89.75 | |
| #110936 | Grace Jensen | Paid | PayPal | Feb 27, 2026 | $12.50 | |
| #110935 | Daniel Jensen | Paid | Apple Pay | Feb 26, 2026 | $161.75 | |
| #110934 | Mark Patel | Refunded | Visa •••• 2290 | Feb 25, 2026 | $17.00 | |
| #110933 | Olivia Clark | Refunded | Apple Pay | Feb 24, 2026 | $306.25 | |
| #110932 | Emma Lopez | Paid | Bank transfer | Feb 23, 2026 | $216.75 | |
| #110931 | Ava Taylor | Refunded | Visa •••• 2290 | Feb 22, 2026 | $112.25 | |
| #110930 | Lucas Garcia | Pending | PayPal | Feb 21, 2026 | $102.25 | |
| #110929 | Olivia Garcia | Paid | PayPal | Feb 20, 2026 | $15.75 | |
| #110928 | Henry Carter | Paid | Bank transfer | Feb 19, 2026 | $111.75 | |
| #110927 | Liam Carter | Pending | Bank transfer | Feb 18, 2026 | $387.50 | |
| #110926 | Ava Miller | Pending | Apple Pay | Feb 17, 2026 | $70.00 | |
| #110925 | Liam Garcia | Paid | Visa •••• 2290 | Feb 16, 2026 | $287.75 | |
| #110924 | Ava Moore | Paid | Mastercard •••• 8034 | Feb 15, 2026 | $96.50 |
<div class="card">
<header>
<h2>Orders</h2>
<p data-table-info="orders-demo" aria-live="polite"></p>
<aside>
<div class="input-group"><span><i class="icon icon-search" aria-hidden="true"></i></span><input type="search" placeholder="Search orders" aria-label="Search orders" data-table-search="orders-demo" /></div>
<button type="button" class="btn" data-size="sm" popovertarget="orders-cols"><i class="icon icon-columns-3" aria-hidden="true"></i>Columns</button>
<div class="dropdown" id="orders-cols" data-table-columns="orders-demo" popover></div>
</aside>
</header>
<div class="scroll-area" tabindex="0" style="max-height: 26rem">
<table class="table" style="min-width: 60rem" id="orders-demo" data-page-size="10" data-sticky data-table-remember>
<thead>
<tr>
<th scope="col"><input type="checkbox" aria-label="Select all orders" /></th>
<th scope="col" aria-sort="none"><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">Status</button></th>
<th scope="col" aria-sort="none"><button type="button">Payment</button></th>
<th scope="col" aria-sort="none"><button type="button">Date</button></th>
<th scope="col" data-align="end" aria-sort="none"><button type="button">Total</button></th>
</tr>
<tr data-filters>
<th></th>
<th><input class="input" data-size="sm" type="search" aria-label="Filter by order" placeholder="Filter" /></th>
<th><input class="input" data-size="sm" type="search" aria-label="Filter by customer" placeholder="Filter" /></th>
<th><select class="select" data-size="sm" aria-label="Filter by status"><option value="">All</option><option>Paid</option><option>Pending</option><option>Refunded</option></select></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td><input type="checkbox" aria-label="Select order 110963" /></td><td>#110963</td><td>Liam Nguyen</td><td><span class="badge" data-variant="warning">Pending</span></td><td>Bank transfer</td><td>Mar 28, 2026</td><td data-align="end">$90.00</td></tr>
<tr><td><input type="checkbox" aria-label="Select order 110962" /></td><td>#110962</td><td>Lucas Garcia</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Apple Pay</td><td>Mar 27, 2026</td><td data-align="end">$178.50</td></tr>
<tr><td><input type="checkbox" aria-label="Select order 110961" /></td><td>#110961</td><td>Mark Moore</td><td><span class="badge" data-variant="ok">Paid</span></td><td>Visa •••• 4417</td><td>Mar 26, 2026</td><td data-align="end">$285.25</td></tr>
<!-- more rows -->
</tbody>
<template data-table-empty><p>No orders match your filters.</p></template>
</table>
</div>
<footer>
<p id="orders-selected" aria-live="polite">0 selected</p>
<select class="select" data-size="sm" aria-label="Rows per page" data-table-page-size="orders-demo"><option>10</option><option>20</option><option>40</option></select>
<nav class="pagination" data-size="sm" aria-label="Pages" data-pagination-for="orders-demo"></nav>
</footer>
</div>
<script>
document.getElementById("orders-demo").addEventListener("tsunami:select", (e) => {
document.getElementById("orders-selected").textContent = `${e.detail.count} selected`;
});
</script>
Attributes
| Attribute / element | Values | Description |
|---|---|---|
.table |
table |
Full width, tabular numbers, small gray headers and row dividers. Rows get a gray fill on hover |
caption |
Table description. Small, gray, left-aligned above the headers | |
thead th |
Column header. 12px, gray, not bold, no wrapping | |
tbody th[scope="row"] |
Cell that names the row. Dark, weight 500 | |
tfoot |
Total row: top border, bold | |
data-align |
end; omit for left-aligned (default) |
On th/td: right-aligned, for
money and numbers
|
data-truncate |
(boolean) |
On td: takes the remaining space, cut off
with …, never narrower than 8rem; put the full text in
title
|
td > .avatar / img:first-child |
Picture before the text in a cell (img is 32px with rounded corners) | |
th[aria-sort] |
none, ascending,
descending
|
Sortable column with a direction icon. JS sets it on click |
th[aria-sort] > button |
Sort button. A click toggles ascending/descending (use
a for server-side sorting)
|
|
th[data-sort-type] |
number, date,
text; omit to detect (default)
|
Forces the column's sort type |
td[data-sort-value] |
Any value |
Value to sort by instead of the text, such as
2026-03-02
|
td:first-child > input[type=checkbox]
|
Row checkbox. Selected rows get a gray fill (CSS
:has)
|
|
th:first-child > input[type=checkbox]
|
In thead: selects all. Indeterminate when
some rows are selected
|
|
tr[aria-selected] |
true, false |
Set by JS from the row's checkbox |
data-page-size |
A number |
On the table: rows per page. Rows outside the page get
hidden
|
nav.pagination[data-pagination-for] |
Table id; omit the value to use the table in the same card | Filled with prev, page numbers (with …), next as buttons. Hidden when there is one page |
[data-table-info] |
Table id | Shows the range, such as 1–10 of 42 |
select[data-table-page-size] |
Table id |
Changes data-page-size and goes back to
page 1
|
input[data-table-search] |
Table id | Global search: rows whose cells contain the text, ignoring case (150 ms debounce) |
thead tr[data-filters] |
Row of column filters. An input in a cell
filters by contains, a select by equals, on
the column at the cell's index
|
|
template[data-table-empty] |
Optional table id |
Content for the row shown when no row matches, spanning
the visible columns. A
tr[data-table-empty] in
tbody works too
|
[data-table-columns] |
Table id |
A .dropdown of column checkboxes (value =
column index). Written from the headers when empty.
Columns are hidden with hidden
|
data-table-remember |
(boolean) |
On a table with an id: keep the hidden
columns in localStorage
|
data-sticky |
both; omit for the header only |
On the table: sticky header inside its scroll container.
both adds the first column
|
data-enhanced |
Set by JS on a table that pages or filters; rows then draw their divider on top so a hidden last row leaves no stray line | |
.card > .table, .card > .scroll-area >
.table
|
Table runs to the card's edges | |
.scroll-area > .table |
Wraps a wide table so it scrolls horizontally on narrow screens |
API
src/js/table.js uses event delegation on
document, so it works on tables added later right
away. The functions live at window.tsunami.table.
Sorting, filtering and paging work together: the rows are sorted,
filtered, then cut into pages.
| Function / event | Arguments / detail | Description |
|---|---|---|
sort(table, columnIndex, direction) |
table: element, columnIndex:
index of the header cell, direction:
"ascending" (default) or
"descending"
|
Sorts the rows in tbody and sets
aria-sort. Returns false if
the event was canceled
|
select(table, rows, checked) |
rows: a list of tr,
checked: boolean (default
true)
|
Selects or deselects rows, then fires
tsunami:select
|
selected(table) |
table: element |
Returns an array of the selected
tr elements
|
sync(table) |
table: element |
Resets aria-selected and the select-all
checkbox state after you change checkboxes yourself
(runs on page load automatically)
|
refresh(table) |
table: element |
Reads the rows again after you add, remove or change them, and redraws the page |
page(table, n) |
n: page number (kept within range) |
Shows page n |
filter(table, text) |
text: the search text;
"" clears it
|
Sets the global search (and the search input), goes to page 1 |
tsunami:tablechange |
{ page, pageSize, total, filtered, sort, hidden
}
|
Fired on the table after a page, filter,
sort or column change. Bubbles.
total counts all rows,
filtered those matching;
sort is
{ column, direction } or null;
hidden lists hidden column indexes
|
tsunami:sort |
{ column, direction, header } |
Fired on the table before sorting. Bubbles
and is cancelable: call preventDefault() to
sort yourself (such as fetching from the server)
|
tsunami:select |
{ rows, count } |
Fired on the table whenever the selection
changes. Bubbles
|