// pattern

Accessible Data Table Pattern

A sortable data table using native <table> elements with <caption>, <th scope="col">, sortable column headers with aria-sort, and a live region to announce sort changes. Native HTML does the heavy lifting — ARIA fills the gaps for sort state.

native-html intermediate wcag-2.2

// 01 · live demo

Live Demo

Click Name or Start Date column headers to sort. Use Tab to reach sort buttons, Enter/Space to activate.

Team Members
Role Department
Alice Johnson Senior Engineer Engineering 2019-03-15
Bob Martinez Product Manager Product 2020-07-01
Carol Chen UX Designer Design 2021-01-10
David Kim QA Engineer Engineering 2018-11-20
Eva Novak Tech Lead Engineering 2017-06-05
Frank Osei Content Strategist Marketing 2022-04-18
Grace Liu Data Analyst Product 2023-09-02

Inaccessible version (div-based):

Name
Role
Department
Start Date
Alice Johnson
Senior Engineer
Engineering
2019-03-15
Bob Martinez
Product Manager
Product
2020-07-01
Carol Chen
UX Designer
Design
2021-01-10

This version uses <div> elements with CSS grid. No table semantics, no header associations, no caption, no sort controls. Screen readers see a flat list of text with no structure.

// 02 · the code

The Code

HTML
<!-- Scroll wrapper for responsive tables -->
<div class="table-wrapper" role="region" aria-label="Team members" tabindex="0">

  <table>
    <!-- Caption: announced when entering the table -->
    <caption>Team Members</caption>

    <thead>
      <tr>
        <!-- Sortable column: button inside th, aria-sort on th -->
        <th scope="col" aria-sort="none">
          <button type="button" class="sort-btn"
                  data-sort-key="name">
            Name
            <svg class="sort-icon" aria-hidden="true">...</svg>
          </button>
        </th>

        <!-- Non-sortable columns -->
        <th scope="col">Role</th>
        <th scope="col">Department</th>

        <!-- Sortable column -->
        <th scope="col" aria-sort="none">
          <button type="button" class="sort-btn"
                  data-sort-key="startDate">
            Start Date
            <svg class="sort-icon" aria-hidden="true">...</svg>
          </button>
        </th>
      </tr>
    </thead>

    <tbody>
      <tr>
        <td>Alice Johnson</td>
        <td>Senior Engineer</td>
        <td>Engineering</td>
        <td>2019-03-15</td>
      </tr>
      <!-- More rows... -->
    </tbody>
  </table>

  <!-- Live region for sort announcements -->
  <div class="sr-only" aria-live="polite"
       id="sort-announcement"></div>

</div>
CSS
/* Responsive horizontal scroll wrapper */
.table-wrapper {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  border: 1px solid #d1d5db;
  border-radius: 0.5rem;
}

table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.875rem;
}

caption {
  padding: 0.75rem 1.5rem;
  font-weight: 700;
  font-size: 1rem;
  text-align: left;
  border-bottom: 1px solid #d1d5db;
}

th {
  padding: 0.5rem 1.5rem;
  text-align: left;
  font-weight: 600;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: #6b7280;
  border-bottom: 2px solid #d1d5db;
  white-space: nowrap;
}

td {
  padding: 0.5rem 1.5rem;
  border-bottom: 1px solid #e5e7eb;
}

/* Hover row highlight */
tbody tr:hover {
  background: rgba(0, 0, 0, 0.02);
}

tbody tr:last-child td {
  border-bottom: none;
}

/* Sort button: fills the entire th */
.sort-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  /* SC 2.5.8: minimum 24x24 target */
  min-height: 2.75rem;
  width: 100%;
}

.sort-btn:hover {
  color: #5b2a86;
}

/* SC 2.4.13: focus indicator with 3:1 contrast */
.sort-btn:focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: 2px;
  border-radius: 0.25rem;
}

/* Sort icon: chevron rotates for direction */
.sort-icon {
  width: 14px;
  height: 14px;
  transition: transform 0.15s ease;
  opacity: 0.4;
  flex-shrink: 0;
}

/* Active sort direction indicators */
[aria-sort="ascending"] .sort-icon,
[aria-sort="descending"] .sort-icon {
  opacity: 1;
}

[aria-sort="descending"] .sort-icon {
  transform: rotate(180deg);
}

/* Sticky header option */
thead {
  position: sticky;
  top: 0;
  z-index: 1;
  background: #fff;
}

/* Visually hidden but accessible */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .sort-icon {
    transition: none;
  }
}
JavaScript
function initSortableTable(tableWrapper) {
  const table = tableWrapper.querySelector('table');
  const tbody = table.querySelector('tbody');
  const announcement = tableWrapper.querySelector(
    '[aria-live="polite"]'
  );
  const sortButtons = table.querySelectorAll('.sort-btn');

  sortButtons.forEach(button => {
    button.addEventListener('click', () => {
      const th = button.closest('th');
      const key = button.dataset.sortKey;
      const colIndex = Array.from(
        th.parentElement.children
      ).indexOf(th);

      // Determine new sort direction
      const currentSort = th.getAttribute('aria-sort');
      const newSort = currentSort === 'ascending'
        ? 'descending'
        : 'ascending';

      // Reset all sortable columns to "none"
      table.querySelectorAll('th[aria-sort]')
        .forEach(col => {
          col.setAttribute('aria-sort', 'none');
        });

      // Set new sort direction on this column
      th.setAttribute('aria-sort', newSort);

      // Sort the rows
      const rows = Array.from(tbody.querySelectorAll('tr'));
      rows.sort((a, b) => {
        const aVal = a.children[colIndex].textContent.trim();
        const bVal = b.children[colIndex].textContent.trim();

        // Check if values are dates (YYYY-MM-DD)
        const aDate = Date.parse(aVal);
        const bDate = Date.parse(bVal);
        if (!isNaN(aDate) && !isNaN(bDate)) {
          return newSort === 'ascending'
            ? aDate - bDate
            : bDate - aDate;
        }

        // String comparison
        const compare = aVal.localeCompare(bVal);
        return newSort === 'ascending'
          ? compare
          : -compare;
      });

      // Re-render tbody
      rows.forEach(row => tbody.appendChild(row));

      // Announce sort change via live region
      const colName = button.textContent.trim();
      announcement.textContent =
        `Sorted by ${colName}, ${newSort}`;
    });
  });
}

// Initialize all sortable tables
document.querySelectorAll('.table-wrapper')
  .forEach(initSortableTable);
Why a scroll wrapper? On narrow viewports, data tables often overflow. Wrapping the table in a scrollable <div> with role="region" and aria-label lets screen reader users discover the scrollable area. Adding tabindex="0" makes it keyboard-scrollable for sighted keyboard users.

// 03 · why these decisions

Why These Decisions

Why native <table> instead of divs with CSS grid

Screen readers provide dedicated table navigation commands that only work with real <table> elements. In JAWS and NVDA, users press Ctrl+Alt+Arrow to move between cells, and the screen reader announces the column header as they move. With div-based layouts, these commands do nothing — the user has no way to navigate the data or understand which column a value belongs to. Adding role="table" to a div is technically possible but fragile and unnecessary when the native element exists.

Why <caption>

The <caption> element identifies the table's purpose and is announced automatically when a screen reader enters the table. NVDA announces "Team Members table with 7 rows and 4 columns" — the caption provides the "Team Members" part. Without it, screen readers announce "table with 7 rows and 4 columns" and the user has no idea what the data represents until they start exploring individual cells.

Why scope="col" on <th> elements

The scope attribute explicitly tells the browser and assistive technology whether a header applies to its column or its row. While browsers can usually infer this from the table structure, the explicit attribute removes all ambiguity. When a screen reader user navigates to a cell, it announces the associated column header — but only if the association is correctly established. The scope attribute guarantees this works in all screen readers and table configurations.

Why <button> inside sortable <th>

A <button> inside the header cell provides a clear, keyboard-accessible click target. Making the <th> itself clickable via an onclick handler or JavaScript would require adding tabindex, a role, and keydown event handling manually. Buttons give you all of this for free: they are focusable, they activate on Enter and Space, and they are announced as interactive elements by screen readers.

Why aria-sort

The aria-sort attribute is the only way to communicate the current sort state to screen readers. When a user navigates to a sortable column header, the screen reader announces "sorted ascending" or "sorted descending" along with the header text. Without this attribute, the user has no way to know which column is sorted or in which direction — they would need to read through the data and infer the sort order themselves.

Why a live region for sort announcements

When the table re-sorts, the DOM changes happen in <tbody> — but the user's focus stays on the sort button in <thead>. Screen readers don't automatically announce that the table content has changed. An aria-live="polite" region solves this: when its text content changes, the screen reader announces the new text (e.g., "Sorted by Name, ascending") without moving focus. This gives the user immediate confirmation that their action worked.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Tab Moves focus to the next sortable column header button
Enter / Space Sorts the table by that column, toggling between ascending and descending
Ctrl+Alt+Arrow Table cell navigation in screen reader mode (JAWS/NVDA). Moves between cells and announces column headers.
Screen reader table navigation The Ctrl+Alt+Arrow keys are screen reader commands, not something you implement in JavaScript. They work automatically when the table uses native <table>, <th>, and <td> elements. VoiceOver uses VO+Arrow for the same purpose.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 1.3.1 Info and Relationships Level A — Semantic table structure with <table>, <th>, scope, and <caption> conveys data relationships programmatically.
  • 1.3.2 Meaningful Sequence Level A — Table data reads in a logical order: caption, then headers, then rows from top to bottom, cells from left to right.
  • 2.1.1 Keyboard Level A — Sort buttons are keyboard operable via Tab to focus and Enter/Space to activate.
  • 4.1.2 Name, Role, Value Level A — Sortable column headers expose their sort state via aria-sort, and sort buttons provide accessible names from their text content.
  • 2.4.6 Headings and Labels Level AA — The <caption> element describes the table's purpose, and column headers label each data column.
  • 2.4.13 Focus Appearance Level AAA — Sort buttons have visible focus indicators using a 2px solid outline with 3:1 contrast ratio.
  • 2.5.8 Target Size (Minimum) Level AA — Sort buttons meet the 24x24px minimum target size via min-height: 2.75rem and width: 100%.
  • 4.1.3 Status Messages Level AA — Sort changes are announced via an aria-live="polite" region without moving focus, so screen reader users know the table has been re-sorted.

// 06 · screen reader behavior

Screen Reader Behavior

When entering the table

  • NVDA: "Team Members table with 7 rows and 4 columns" — announces the caption, element type, and dimensions
  • JAWS: "Team Members, table with 7 rows and 4 columns" — similar table mode entry announcement
  • VoiceOver: "Team Members, table, 4 columns, 7 rows" — caption, type, and dimensions in slightly different order

When navigating to a sortable column header

  • NVDA: "Name column 1, sorted ascending, button" — announces header text, column position, sort state, and the button role
  • JAWS: "Name, column header, sort ascending" — header text, role, and current sort direction
  • VoiceOver: "Name, sort button, sorted ascending" — header text, button role, and sort state

When navigating cells with table commands

  • NVDA: Pressing Ctrl+Alt+Down moves to the cell below; announces "Alice Johnson, row 2, Name" — the cell content, row position, and column header
  • JAWS: Similar cell-by-cell navigation with header announcements at each position change
  • VoiceOver: Uses VO+Arrow for cell navigation; announces cell content and associated headers

After sorting

When a sort button is activated, the live region announces "Sorted by Name, ascending" (or the relevant column and direction). Focus remains on the sort button, and the user can continue navigating the re-sorted table immediately.

// 07 · common mistakes

Common Mistakes

Using divs with CSS grid instead of <table> Screen reader table navigation commands (Ctrl+Alt+Arrow) only work with native <table> elements. A div-based grid layout looks like a table visually but provides no table semantics — screen reader users cannot navigate between cells or hear column headers announced as they move through the data.
Missing <th> or scope attributes Without <th> elements, screen readers cannot identify column headers. Without scope="col", the association between headers and their data cells becomes ambiguous, especially in complex tables. Users hear cell content without knowing which column it belongs to.
No <caption> element Without a caption, the table has no programmatic label. Screen readers announce "table with 7 rows and 4 columns" but the user has no idea what the table contains until they start exploring individual cells. The caption provides immediate context.
Sort state not communicated with aria-sort If sortable columns lack the aria-sort attribute, screen reader users have no way to know which column the table is sorted by or in which direction. They would need to read through the data and infer the sort order, which is impractical for large datasets.
Making the entire <th> clickable without a <button> Adding a click handler directly to a <th> element makes it mouse-operable but not keyboard-accessible. <th> elements are not focusable by default and do not respond to Enter or Space. A <button> inside the <th> provides all of this natively.
No announcement when sort changes When a table re-sorts, the content in <tbody> changes but focus stays on the sort button. Screen readers do not automatically announce DOM changes outside the focused element. Without an aria-live region, users get no feedback that their sort action worked and the data has been reordered.

// 08 · native html vs. aria

Native HTML vs. ARIA

Data tables are a case where native HTML handles most of the work. ARIA is only needed for sort state and announcements — the table structure itself should always use native elements.

Feature Native HTML ARIA
Table structure <table> — automatic screen reader table navigation role="table" — unnecessary with native element
Column headers <th scope="col"> — announced during cell navigation role="columnheader" — unnecessary with native element
Row headers <th scope="row"> — announces row context role="rowheader" — unnecessary with native element
Caption <caption> — announced on table entry aria-label on table — use only when caption is not visible
Sort state None — no native HTML sort mechanism aria-sort="ascending/descending/none" — required
Sort announcement None — no native mechanism for dynamic content changes aria-live region — required for sort change announcements
Cell navigation Automatic in screen readers with native tables Works only with real <table> elements or correct ARIA roles
Header association scope attribute — simple column/row association headers attribute — for complex tables with spanning cells
Data cells <td> — automatic cell semantics role="cell" — unnecessary with native element
The verdict Data tables are the opposite of tabs: native HTML handles almost everything. Use <table>, <th>, <td>, <caption>, and scope for the structure. ARIA only steps in for sort state (aria-sort) and sort announcements (aria-live). If you find yourself adding role="table" or role="cell", you are almost certainly using the wrong HTML elements.