Skip to main content
Back to Blog
Tutorials
4 min read
December 16, 2024

How to Build Accessible Data Tables in React

Build a fully accessible data table in React with ARIA attributes, keyboard navigation, sorting, pagination, and screen reader support.

Ryel Banfield

Founder & Lead Developer

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.

accessibilitydata tableARIAReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles