Github

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

Invoices
INV-001PaidCard$250.00
INV-002PendingPayPal$150.00
INV-003OverdueBank$350.00
INV-004PaidCard$450.00
INV-005PaidBank$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.

People
Ada LovelaceMathematicianLondon
Alan TuringComputer scientistManchester
Claude ShannonMathematicianGaylord
Dorothy VaughanComputer programmerHampton

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>