Data Table
Try in playground →opens this recipe’s markup and SCSS, live-compiled
Live: click a column header to sort (three states — ascending, descending, then back to original order), and page through the result with the pager below. Real state, not a static table.
| Ada Lovelace | Engineer | 2024-03-01 |
| Grace Hopper | Architect | 2022-11-14 |
| Alan Turing | Researcher | 2023-06-20 |
Use this when
You're rendering a list of records where the user needs to sort by a column and page through results — an admin list, a report, search results. This recipe covers the core pattern: sortable columns, a stable sort state, and pagination. It does not cover every feature a full data-grid library has (column resizing, drag-to-reorder, cell editing, virtualized scrolling) — reach for a library when you genuinely need those; most lists don't.
Two layout choices, same data model:
<table>(the default below) — use this whenever you can. Real table semantics, free screen-reader table navigation, no JavaScript needed for the layout itself.- CSS Grid with
role="table"— reach for this only when a real<table>can't do what you need (sticky/pinned columns with independent horizontal scroll regions, or row virtualization where absolute positioning inside a grid is easier than inside a table). It needs explicitrole="table"/role="row"/role="columnheader"/role="cell"to stay screen-reader-navigable as a table, since a<div>grid has none of that semantics for free.
Structure (raw HTML)
<div class="my-table-wrap">
<table class="my-table">
<caption class="cia-sr-only">Team members</caption>
<thead>
<tr>
<th scope="col" aria-sort="none">
<button type="button" data-slot="sort" data-key="name">Name</button>
</th>
<th scope="col" aria-sort="ascending">
<button type="button" data-slot="sort" data-key="role">Role</button>
</th>
<th scope="col" aria-sort="none">
<button type="button" data-slot="sort" data-key="joined">Joined</button>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ada Lovelace</td>
<td>Engineer</td>
<td>2024-03-01</td>
</tr>
<!-- …more rows… -->
</tbody>
</table>
</div>
<nav aria-label="Pagination" class="my-table-pager">
<button type="button" data-slot="prev" aria-label="Previous page">‹</button>
<span aria-live="polite">Page 1 of 4</span>
<button type="button" data-slot="next" aria-label="Next page">›</button>
</nav>
Notes on the markup:
- The sort control is a
<button>inside the<th>, not the<th>itself made clickable — a bare clickable table header has no keyboard affordance and no accessible name distinct from the column label. aria-sortlives on the<th>, not the button — it's the column header's state, and it's how a screen reader announces "Role, column, sorted ascending."- The pager here is intentionally minimal (prev/next + a page count) — a fuller pager with numbered pages and first/last jumps is its own future recipe; this is the smallest viable version so the table recipe isn't blocked on it.
Styling (cia mixins)
@use 'css-is-awesome/api' as cia;
.my-table-wrap {
@include cia.table-responsive; // horizontal scroll on narrow viewports
}
.my-table {
@include cia.table-base($striped: true, $hover: true);
th {
padding: 0; // the button below owns the padding, so its hit target
// fills the whole header cell, not just the text
[data-slot="sort"] {
@include cia.button-reset;
display: flex;
align-items: center;
gap: cia.space(1);
width: 100%;
padding: cia.space(2) cia.space(4);
cursor: pointer;
text-align: start;
@include cia.font(semibold, 1);
color: var(--text-secondary);
text-transform: uppercase;
&::after {
content: "↕";
opacity: 0.4;
}
}
&[aria-sort="ascending"] [data-slot="sort"]::after {
content: "↑";
opacity: 1;
}
&[aria-sort="descending"] [data-slot="sort"]::after {
content: "↓";
opacity: 1;
}
}
}
.my-table-pager {
display: flex;
align-items: center;
gap: cia.space(3);
margin-block-start: cia.space(4);
button {
@include cia.btn(ghost);
}
}
table-base's $striped/$hover flags are the only styling decisions
this recipe adds — the sort-indicator and pager are new, small, and built
from button-reset/btn/tokens already in every other recipe in this book.
Interactivity
type SortState = { key: string; direction: "asc" | "desc" } | null;
function nextSortState(current, key) {
if (!current || current.key !== key) return { key, direction: "asc" };
if (current.direction === "asc") return { key, direction: "desc" };
return null; // third click clears the sort — back to original order
}
function sortRows(rows, sort) {
if (!sort) return rows;
const dir = sort.direction === "asc" ? 1 : -1;
return [...rows].sort((a, b) => (a[sort.key] > b[sort.key] ? dir : a[sort.key] < b[sort.key] ? -dir : 0));
}
- Native: the table itself, its row/cell rendering — no JS needed for the layout.
- JS: the sort-state cycle (unsorted → ascending → descending →
unsorted, not a two-way toggle — losing the ability to get back to the
original order is a common, avoidable annoyance) and re-rendering
aria-sorton the active<th>. - Pagination: slice the sorted array by page — sort then paginate, never the reverse, or page 2 shows the wrong rows the moment a sort is applied.
- Edge cases: clearing a sort (the third click) should return rows to their original order, which means keeping an unsorted copy of the data around — sorting in place and trying to "unsort" by reversing twice loses the original order the moment any row is added or removed.
A11y checklist
-
aria-sortis set on the<th>("none"|"ascending"|"descending"), reflecting current state — not just a visual arrow icon (WAI-ARIA: aria-sort) - The sort control is a real
<button>, reachable and activatable by keyboard alone (WCAG 2.2 SC 2.1.1 Keyboard) - The table has a
<caption>(visually hidden viacia.sr-onlyif a visible one is redundant with a page heading) — a screen reader user landing on the table needs to know what it's a table of (WCAG 2.2 SC 1.3.1 Info and Relationships) - The pager's current-page text is in an
aria-live="polite"region so a sort/page change is announced without moving focus (WCAG 2.2 SC 4.1.3 Status Messages)
Framework examples
React
"use client";
import { useMemo, useState } from "react";
type Row = { id: string; name: string; role: string; joined: string };
type Sort = { key: keyof Row; direction: "asc" | "desc" } | null;
export function DataTable({ rows }: { rows: Row[] }) {
const [sort, setSort] = useState<Sort>(null);
function toggleSort(key: keyof Row) {
setSort((s) => (!s || s.key !== key ? { key, direction: "asc" } : s.direction === "asc" ? { key, direction: "desc" } : null));
}
const sorted = useMemo(() => {
if (!sort) return rows;
const dir = sort.direction === "asc" ? 1 : -1;
return [...rows].sort((a, b) => (a[sort.key] > b[sort.key] ? dir : a[sort.key] < b[sort.key] ? -dir : 0));
}, [rows, sort]);
const ariaSort = (key: keyof Row) => (sort?.key === key ? (sort.direction === "asc" ? "ascending" : "descending") : "none");
return (
<table>
<caption className="cia-sr-only">Team members</caption>
<thead>
<tr>
{(["name", "role", "joined"] as const).map((key) => (
<th key={key} scope="col" aria-sort={ariaSort(key)}>
<button type="button" onClick={() => toggleSort(key)}>
{key}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((r) => (
<tr key={r.id}>
<td>{r.name}</td>
<td>{r.role}</td>
<td>{r.joined}</td>
</tr>
))}
</tbody>
</table>
);
}
Vue
<template>
<table>
<caption class="cia-sr-only">Team members</caption>
<thead>
<tr>
<th v-for="key in keys" :key="key" scope="col" :aria-sort="ariaSort(key)">
<button type="button" @click="toggleSort(key)">{{ key }}</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="r in sorted" :key="r.id">
<td>{{ r.name }}</td>
<td>{{ r.role }}</td>
<td>{{ r.joined }}</td>
</tr>
</tbody>
</table>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
type Row = { id: string; name: string; role: string; joined: string };
const props = defineProps<{ rows: Row[] }>();
const keys = ["name", "role", "joined"] as const;
const sort = ref<{ key: (typeof keys)[number]; direction: "asc" | "desc" } | null>(null);
function toggleSort(key: (typeof keys)[number]) {
const s = sort.value;
sort.value = !s || s.key !== key ? { key, direction: "asc" } : s.direction === "asc" ? { key, direction: "desc" } : null;
}
function ariaSort(key: string) {
if (sort.value?.key !== key) return "none";
return sort.value.direction === "asc" ? "ascending" : "descending";
}
const sorted = computed(() => {
if (!sort.value) return props.rows;
const { key, direction } = sort.value;
const dir = direction === "asc" ? 1 : -1;
return [...props.rows].sort((a, b) => (a[key] > b[key] ? dir : a[key] < b[key] ? -dir : 0));
});
</script>
Svelte
<script lang="ts">
type Row = { id: string; name: string; role: string; joined: string };
export let rows: Row[];
const keys = ["name", "role", "joined"] as const;
let sort: { key: (typeof keys)[number]; direction: "asc" | "desc" } | null = null;
function toggleSort(key: (typeof keys)[number]) {
sort = !sort || sort.key !== key ? { key, direction: "asc" } : sort.direction === "asc" ? { key, direction: "desc" } : null;
}
function ariaSort(key: string) {
if (sort?.key !== key) return "none";
return sort.direction === "asc" ? "ascending" : "descending";
}
$: sorted = (() => {
if (!sort) return rows;
const { key, direction } = sort;
const dir = direction === "asc" ? 1 : -1;
return [...rows].sort((a, b) => (a[key] > b[key] ? dir : a[key] < b[key] ? -dir : 0));
})();
</script>
<table>
<caption class="cia-sr-only">Team members</caption>
<thead>
<tr>
{#each keys as key}
<th scope="col" aria-sort={ariaSort(key)}>
<button type="button" on:click={() => toggleSort(key)}>{key}</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted as r (r.id)}
<tr>
<td>{r.name}</td>
<td>{r.role}</td>
<td>{r.joined}</td>
</tr>
{/each}
</tbody>
</table>
Vanilla
<script type="module">
let sort = null; // { key, direction } | null
function toggleSort(key) {
sort = !sort || sort.key !== key ? { key, direction: "asc" } : sort.direction === "asc" ? { key, direction: "desc" } : null;
render();
}
function render() {
document.querySelectorAll("[data-slot='sort']").forEach((btn) => {
const th = btn.closest("th");
const key = btn.dataset.key;
th.setAttribute("aria-sort", sort?.key === key ? (sort.direction === "asc" ? "ascending" : "descending") : "none");
});
// …re-render <tbody> from the sorted array…
}
document.querySelectorAll("[data-slot='sort']").forEach((btn) => {
btn.addEventListener("click", () => toggleSort(btn.dataset.key));
});
</script>
Pitfalls
- A two-way sort toggle loses the "back to original order" state. Cycle through three states (asc → desc → none), not two, or a user who overshoots the sort they wanted has no way back except reloading.
- Sorting strings and numbers with the same comparator silently breaks
numeric columns —
"10" < "9"is true as strings. If a column's values are numeric, sort numerically, not with the generic>/<comparator shown above (which is fine for strings and ISO dates, not for raw numbers). - Don't reach for CSS Grid
role="table"by default. It needs every ARIA role hand-applied to stay a table to assistive tech, where a real<table>gets that for free. Only reach for it when a real table genuinely can't do what you need (see Use this when).
Related recipes
app-shell— a toolbar row (filters, search, actions) commonly sits above a table like this one.