Data table
Compose Table (.table in .table-wrap) and Pagination (nav.pagination).
A <button class="table-sort"> in each <th> toggles ascending or descending.
Set aria-sort on the header.
Pagination slices the sorted rows — this page keeps
twelve invoices and five per page.
Tiny $state for sort key, direction, and page
index. There is no wrapper and no table engine. Not TanStack.
A second example binds rows, columns, and page size to
variables.
Preview
| INV-001 | Paid | Card | $250.00 |
| INV-002 | Pending | PayPal | $150.00 |
| INV-003 | Overdue | Bank | $350.00 |
| INV-004 | Paid | Card | $450.00 |
| INV-005 | Paid | Bank | $550.00 |
Markup
<div class="table-wrap">
<table class="table">
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col" aria-sort="ascending">
<button class="table-sort" type="button">
Invoice
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m6 15 6-6 6 6"></path>
</svg>
</button>
</th>
<th scope="col" aria-sort="none">
<button class="table-sort" type="button">
Status
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m6 15 6-6 6 6"></path>
</svg>
</button>
</th>
<th scope="col" aria-sort="none">
<button class="table-sort" type="button">
Amount
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m6 15 6-6 6 6"></path>
</svg>
</button>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>INV-001</td>
<td><span class="badge badge-secondary">Paid</span></td>
<td>$250.00</td>
</tr>
<tr>
<td>INV-002</td>
<td><span class="badge badge-outline">Pending</span></td>
<td>$150.00</td>
</tr>
</tbody>
</table>
</div>
<nav class="pagination" aria-label="Pagination">
<button class="button button-outline button-sm" type="button" disabled aria-disabled="true">Previous</button>
<button class="button button-sm" type="button" aria-current="page">1</button>
<button class="button button-outline button-sm" type="button">2</button>
<button class="button button-outline button-sm" type="button">3</button>
<button class="button button-outline button-sm" type="button">Next</button>
</nav>Dynamic
Rows are a $state array of objects. Column headers
come from the keys on those objects — swap the list and the
columns follow. Page size is a variable, not a hardcoded 5. Sort the full list, then slice with page_size. Same .table / .pagination markup as above.
| Ada Lovelace | Mathematician | London |
| Alan Turing | Computer scientist | Manchester |
| Claude Shannon | Mathematician | Gaylord |
| Dorothy Vaughan | Computer programmer | Hampton |
4 per page · 8 rows
<script>
let page_size = $state(4);
let rows = $state([
{ name: 'Ada Lovelace', role: 'Mathematician', city: 'London' },
{ name: 'Alan Turing', role: 'Computer scientist', city: 'Manchester' },
{ name: 'Grace Hopper', role: 'Rear admiral', city: 'New York' },
{ name: 'Katherine Johnson', role: 'Mathematician', city: 'Hampton' },
{ name: 'Dorothy Vaughan', role: 'Computer programmer', city: 'Hampton' },
{ name: 'Margaret Hamilton', role: 'Software engineer', city: 'Cambridge' },
{ name: 'Hedy Lamarr', role: 'Inventor', city: 'Vienna' },
{ name: 'Claude Shannon', role: 'Mathematician', city: 'Gaylord' }
]);
let sort_key = $state('name');
let sort_dir = $state('asc');
let page = $state(0);
const columns = $derived(Object.keys(rows[0] ?? {}));
const sorted = $derived((() => {
const copy = rows.slice();
copy.sort((a, b) => {
const av = a[sort_key];
const bv = b[sort_key];
const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv), 'en');
return sort_dir === 'asc' ? cmp : -cmp;
});
return copy;
})());
const page_count = $derived(Math.max(1, Math.ceil(sorted.length / page_size)));
const visible = $derived(sorted.slice(page * page_size, page * page_size + page_size));
function sort_by(key) {
if (sort_key === key) sort_dir = sort_dir === 'asc' ? 'desc' : 'asc';
else { sort_key = key; sort_dir = 'asc'; }
page = 0;
}
function sort_aria(key) {
if (sort_key !== key) return 'none';
return sort_dir === 'asc' ? 'ascending' : 'descending';
}
function heading(key) {
return key.slice(0, 1).toUpperCase() + key.slice(1);
}
</script>
<div class="table-wrap">
<table class="table">
<caption>People</caption>
<thead>
<tr>
{#each columns as key}
<th scope="col" aria-sort={sort_aria(key)}>
<button class="table-sort" type="button" onclick={() => sort_by(key)}>
{heading(key)}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m6 15 6-6 6 6"></path>
</svg>
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each visible as row (row.name)}
<tr>
{#each columns as key}
<td>{row[key]}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<nav class="pagination" aria-label="Pagination">
<button class="button button-outline button-sm" type="button" disabled={page === 0}>Previous</button>
{#each Array.from({ length: page_count }, (_, i) => i) as i}
<button class={page === i ? 'button button-sm' : 'button button-outline button-sm'} type="button" aria-current={page === i ? 'page' : undefined} onclick={() => (page = i)}>{i + 1}</button>
{/each}
<button class="button button-outline button-sm" type="button" disabled={page >= page_count - 1}>Next</button>
</nav>