Tables are essential for displaying structured data. Here is how to build ones that work for everyone, including screen reader users.
Table Types and Hook
// hooks/useDataTable.ts
"use client";
import { useMemo, useState, useCallback } from "react";
export type SortDirection = "asc" | "desc" | null;
export interface Column<T> {
id: string;
header: string;
accessor: (row: T) => React.ReactNode;
sortable?: boolean;
sortValue?: (row: T) => string | number;
width?: string;
align?: "left" | "center" | "right";
}
interface UseDataTableOptions<T> {
data: T[];
columns: Column<T>[];
pageSize?: number;
initialSort?: { columnId: string; direction: SortDirection };
}
export function useDataTable<T>({
data,
columns,
pageSize = 10,
initialSort,
}: UseDataTableOptions<T>) {
const [sortColumn, setSortColumn] = useState<string | null>(
initialSort?.columnId ?? null
);
const [sortDirection, setSortDirection] = useState<SortDirection>(
initialSort?.direction ?? null
);
const [page, setPage] = useState(0);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const sortedData = useMemo(() => {
if (!sortColumn || !sortDirection) return data;
const column = columns.find((c) => c.id === sortColumn);
if (!column?.sortValue) return data;
return [...data].sort((a, b) => {
const aVal = column.sortValue!(a);
const bVal = column.sortValue!(b);
if (typeof aVal === "string" && typeof bVal === "string") {
return sortDirection === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
return sortDirection === "asc"
? (aVal as number) - (bVal as number)
: (bVal as number) - (aVal as number);
});
}, [data, sortColumn, sortDirection, columns]);
const paginatedData = useMemo(() => {
const start = page * pageSize;
return sortedData.slice(start, start + pageSize);
}, [sortedData, page, pageSize]);
const totalPages = Math.ceil(data.length / pageSize);
const handleSort = useCallback(
(columnId: string) => {
const column = columns.find((c) => c.id === columnId);
if (!column?.sortable) return;
if (sortColumn === columnId) {
setSortDirection((prev) =>
prev === "asc" ? "desc" : prev === "desc" ? null : "asc"
);
if (sortDirection === "desc") setSortColumn(null);
} else {
setSortColumn(columnId);
setSortDirection("asc");
}
setPage(0);
},
[sortColumn, sortDirection, columns]
);
const toggleRow = useCallback((index: number) => {
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
}, []);
const toggleAllRows = useCallback(() => {
setSelectedRows((prev) => {
if (prev.size === paginatedData.length) return new Set();
return new Set(paginatedData.map((_, i) => page * pageSize + i));
});
}, [paginatedData, page, pageSize]);
return {
columns,
data: paginatedData,
sortColumn,
sortDirection,
page,
totalPages,
totalRows: data.length,
selectedRows,
handleSort,
setPage,
toggleRow,
toggleAllRows,
};
}
Accessible Table Component
"use client";
import type { Column, SortDirection } from "@/hooks/useDataTable";
import { useDataTable } from "@/hooks/useDataTable";
import { useId } from "react";
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
caption: string;
pageSize?: number;
selectable?: boolean;
}
export function DataTable<T>({
data,
columns,
caption,
pageSize = 10,
selectable = false,
}: DataTableProps<T>) {
const table = useDataTable({ data, columns, pageSize });
const tableId = useId();
const captionId = `${tableId}-caption`;
return (
<div>
{/* Live region for announcements */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
id={`${tableId}-status`}
>
{table.sortColumn && table.sortDirection
? `Sorted by ${columns.find((c) => c.id === table.sortColumn)?.header}, ${table.sortDirection === "asc" ? "ascending" : "descending"}`
: ""}
. Showing {table.data.length} of {table.totalRows} rows, page {table.page + 1} of {table.totalPages}.
</div>
<div className="border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table
role="table"
aria-labelledby={captionId}
aria-describedby={`${tableId}-status`}
className="w-full text-sm"
>
<caption id={captionId} className="sr-only">
{caption}
</caption>
<thead>
<tr className="border-b bg-muted/50">
{selectable && (
<th scope="col" className="w-10 px-3 py-3">
<input
type="checkbox"
checked={table.selectedRows.size === table.data.length && table.data.length > 0}
onChange={table.toggleAllRows}
aria-label="Select all rows"
className="accent-primary"
/>
</th>
)}
{columns.map((column) => (
<th
key={column.id}
scope="col"
className={`px-4 py-3 font-medium text-muted-foreground ${
column.align === "right"
? "text-right"
: column.align === "center"
? "text-center"
: "text-left"
} ${column.sortable ? "cursor-pointer select-none" : ""}`}
style={{ width: column.width }}
aria-sort={getAriaSortValue(column.id, table.sortColumn, table.sortDirection)}
onClick={() => column.sortable && table.handleSort(column.id)}
onKeyDown={(e) => {
if (column.sortable && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
table.handleSort(column.id);
}
}}
tabIndex={column.sortable ? 0 : undefined}
role={column.sortable ? "columnheader button" : "columnheader"}
>
<span className="inline-flex items-center gap-1">
{column.header}
{column.sortable && (
<SortIndicator
active={table.sortColumn === column.id}
direction={
table.sortColumn === column.id ? table.sortDirection : null
}
/>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{table.data.length === 0 ? (
<tr>
<td
colSpan={columns.length + (selectable ? 1 : 0)}
className="px-4 py-8 text-center text-muted-foreground"
>
No data available.
</td>
</tr>
) : (
table.data.map((row, index) => {
const globalIndex = table.page * pageSize + index;
const isSelected = table.selectedRows.has(globalIndex);
return (
<tr
key={index}
className={`border-b last:border-0 ${
isSelected ? "bg-primary/5" : "hover:bg-muted/30"
}`}
aria-selected={selectable ? isSelected : undefined}
>
{selectable && (
<td className="w-10 px-3 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => table.toggleRow(globalIndex)}
aria-label={`Select row ${globalIndex + 1}`}
className="accent-primary"
/>
</td>
)}
{columns.map((column) => (
<td
key={column.id}
className={`px-4 py-3 ${
column.align === "right"
? "text-right"
: column.align === "center"
? "text-center"
: "text-left"
}`}
>
{column.accessor(row)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{table.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t bg-muted/30">
<span className="text-xs text-muted-foreground">
{table.selectedRows.size > 0
? `${table.selectedRows.size} selected of `
: ""}
{table.totalRows} total rows
</span>
<nav aria-label="Table pagination" className="flex items-center gap-1">
<button
onClick={() => table.setPage(0)}
disabled={table.page === 0}
className="px-2 py-1 text-xs border rounded disabled:opacity-40"
aria-label="First page"
>
First
</button>
<button
onClick={() => table.setPage(table.page - 1)}
disabled={table.page === 0}
className="px-2 py-1 text-xs border rounded disabled:opacity-40"
aria-label="Previous page"
>
Prev
</button>
<span className="px-3 text-xs" aria-current="page">
{table.page + 1} / {table.totalPages}
</span>
<button
onClick={() => table.setPage(table.page + 1)}
disabled={table.page >= table.totalPages - 1}
className="px-2 py-1 text-xs border rounded disabled:opacity-40"
aria-label="Next page"
>
Next
</button>
<button
onClick={() => table.setPage(table.totalPages - 1)}
disabled={table.page >= table.totalPages - 1}
className="px-2 py-1 text-xs border rounded disabled:opacity-40"
aria-label="Last page"
>
Last
</button>
</nav>
</div>
)}
</div>
</div>
);
}
function SortIndicator({
active,
direction,
}: {
active: boolean;
direction: SortDirection;
}) {
return (
<span className={`inline-flex flex-col ${active ? "" : "opacity-30"}`} aria-hidden>
<svg className={`w-3 h-3 -mb-1 ${direction === "asc" ? "text-foreground" : ""}`} viewBox="0 0 12 12">
<path d="M6 2L10 7H2z" fill="currentColor" />
</svg>
<svg className={`w-3 h-3 ${direction === "desc" ? "text-foreground" : ""}`} viewBox="0 0 12 12">
<path d="M6 10L2 5h8z" fill="currentColor" />
</svg>
</span>
);
}
function getAriaSortValue(
columnId: string,
sortColumn: string | null,
sortDirection: SortDirection
): "ascending" | "descending" | "none" | undefined {
if (sortColumn !== columnId) return "none";
if (sortDirection === "asc") return "ascending";
if (sortDirection === "desc") return "descending";
return "none";
}
Usage
import { DataTable } from "@/components/DataTable";
import type { Column } from "@/hooks/useDataTable";
interface User {
name: string;
email: string;
role: string;
status: string;
lastActive: string;
}
const columns: Column<User>[] = [
{
id: "name",
header: "Name",
accessor: (row) => <span className="font-medium">{row.name}</span>,
sortable: true,
sortValue: (row) => row.name,
},
{
id: "email",
header: "Email",
accessor: (row) => row.email,
sortable: true,
sortValue: (row) => row.email,
},
{
id: "role",
header: "Role",
accessor: (row) => row.role,
sortable: true,
sortValue: (row) => row.role,
},
{
id: "status",
header: "Status",
accessor: (row) => (
<span className={`text-xs px-2 py-0.5 rounded-full ${
row.status === "active" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
}`}>
{row.status}
</span>
),
},
];
export default function UsersPage() {
return (
<DataTable
data={users}
columns={columns}
caption="List of all users in the system"
pageSize={10}
selectable
/>
);
}
Need Accessible Web Components?
We build WCAG-compliant interfaces and conduct accessibility audits. Contact us to improve your site accessibility.