Accessibility pattern · Data display

Sortable table

The table stays a real table: a caption names it, th cells head every column and row, and a button in each column header sorts by that column. aria-sort moves to the one header that sets the order, and a status message says the new order in words.

WCAG criteria
7
Keyboard rules
4
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Recent orders Select a column header to sort by it.
#10432 Meera Iyer ₹3,240.00 Processing
#10431 Arjun Mehta ₹12,450.00 Shipped
#10430 Fatima Sheikh ₹860.00 Delivered
#10429 Rohan Das ₹5,999.00 Cancelled
#10428 Asha Rao ₹3,840.00 Delivered
#10427 Kabir Singh ₹1,275.50 Refunded
#10426 Lakshmi Nair ₹24,800.00 Delivered
#10425 Vikram Joshi ₹2,150.00 Shipped
#10424 Priya Kapoor ₹7,320.00 Delivered
#10423 Nikhil Verma ₹640.00 Delivered

2 selected

Sorted by Date, descending

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
Tab or ShiftTabMoves through Select all, the sort buttons, then each row's checkbox; the scroll area is a stop too while it scrolls.
Enter or SpaceOn a sort button, sorts by that column, ascending first; pressing it again reverses the order.
SpaceOn a row's checkbox, selects or clears that row. On Select all, selects every row, or clears them when all are selected.
Arrow Left or Arrow Right or Arrow Up or Arrow DownWith the scroll area focused at narrow widths, scrolls the table sideways or up and down.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Focus reaches the Date sort buttonDate, column header, sorted descending, button
Enter sorts by TotalSorted by Total, ascending
Tab reaches a row's checkbox#10431, Select order #10431, checkbox, checked
Space on Select all while some rows are selectedSelect all orders, checkbox, checked. 10 selected
Tab reaches the scroll area at a narrow widthRecent orders, region

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-dtable" data-ap-dtable>
  <div class="ap-dtable__scroll" role="region" aria-labelledby="dtable-name" tabindex="0" data-ap-scroll>
    <table class="ap-dtable__table">
      <caption class="ap-dtable__cap">
        <span class="ap-dtable__name" id="dtable-name">Recent orders</span>
        <span class="ap-dtable__hint">Select a column header to sort by it.</span>
      </caption>
      <thead>
        <tr>
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select all orders" data-ap-all /></td>
          <th scope="col" data-type="text">
            <button type="button" class="ap-dtable__sort" data-ap-sort>Order no.<svg class="ap-dtable__arrows" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-dtable__up" d="m8 10 4-4 4 4"/><path class="ap-dtable__down" d="m8 14 4 4 4-4"/></svg></button>
          </th>
          <th scope="col" data-type="text">
            <button type="button" class="ap-dtable__sort" data-ap-sort>Customer<svg class="ap-dtable__arrows" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-dtable__up" d="m8 10 4-4 4 4"/><path class="ap-dtable__down" d="m8 14 4 4 4-4"/></svg></button>
          </th>
          <th scope="col" data-type="text" aria-sort="descending">
            <button type="button" class="ap-dtable__sort" data-ap-sort>Date<svg class="ap-dtable__arrows" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-dtable__up" d="m8 10 4-4 4 4"/><path class="ap-dtable__down" d="m8 14 4 4 4-4"/></svg></button>
          </th>
          <th scope="col" class="ap-dtable__num" data-type="number">
            <button type="button" class="ap-dtable__sort" data-ap-sort>Total<svg class="ap-dtable__arrows" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-dtable__up" d="m8 10 4-4 4 4"/><path class="ap-dtable__down" d="m8 14 4 4 4-4"/></svg></button>
          </th>
          <th scope="col" data-type="text">
            <button type="button" class="ap-dtable__sort" data-ap-sort>Status<svg class="ap-dtable__arrows" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-dtable__up" d="m8 10 4-4 4 4"/><path class="ap-dtable__down" d="m8 14 4 4 4-4"/></svg></button>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10432" /></td>
          <th class="ap-dtable__id" scope="row">#10432</th>
          <td>Meera Iyer</td>
          <td class="ap-dtable__date"><time datetime="2026-10-04">4 Oct 2026</time></td>
          <td class="ap-dtable__num" data-value="3240">₹3,240.00</td>
          <td><span class="ap-dtable__status ap-dtable__status--wait"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>Processing</span></td>
        </tr>
        <tr class="is-selected">
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10431" checked /></td>
          <th class="ap-dtable__id" scope="row">#10431</th>
          <td>Arjun Mehta</td>
          <td class="ap-dtable__date"><time datetime="2026-10-03">3 Oct 2026</time></td>
          <td class="ap-dtable__num" data-value="12450">₹12,450.00</td>
          <td><span class="ap-dtable__status ap-dtable__status--ship"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.5 6.5h11v9h-11z"/><path d="M13.5 9.5h4l3 3.2v2.8h-7"/><circle cx="6.5" cy="17.5" r="1.8"/><circle cx="16.5" cy="17.5" r="1.8"/></svg>Shipped</span></td>
        </tr>
        <tr>
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10430" /></td>
          <th class="ap-dtable__id" scope="row">#10430</th>
          <td>Fatima Sheikh</td>
          <td class="ap-dtable__date"><time datetime="2026-10-02">2 Oct 2026</time></td>
          <td class="ap-dtable__num" data-value="860">₹860.00</td>
          <td><span class="ap-dtable__status ap-dtable__status--done"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8.2 12.4 2.6 2.6 5-5.2"/></svg>Delivered</span></td>
        </tr>
        <tr>
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10429" /></td>
          <th class="ap-dtable__id" scope="row">#10429</th>
          <td>Rohan Das</td>
          <td class="ap-dtable__date"><time datetime="2026-10-01">1 Oct 2026</time></td>
          <td class="ap-dtable__num" data-value="5999">₹5,999.00</td>
          <td><span class="ap-dtable__status ap-dtable__status--stop"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m9.2 9.2 5.6 5.6m0-5.6-5.6 5.6"/></svg>Cancelled</span></td>
        </tr>
        <tr class="is-selected">
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10428" checked /></td>
          <th class="ap-dtable__id" scope="row">#10428</th>
          <td>Asha Rao</td>
          <td class="ap-dtable__date"><time datetime="2026-09-30">30 Sep 2026</time></td>
          <td class="ap-dtable__num" data-value="3840">₹3,840.00</td>
          <td><span class="ap-dtable__status ap-dtable__status--done"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8.2 12.4 2.6 2.6 5-5.2"/></svg>Delivered</span></td>
        </tr>
        <tr>
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10427" /></td>
          <th class="ap-dtable__id" scope="row">#10427</th>
          <td>Kabir Singh</td>
          <td class="ap-dtable__date"><time datetime="2026-09-28">28 Sep 2026</time></td>
          <td class="ap-dtable__num" data-value="1275.5">₹1,275.50</td>
          <td><span class="ap-dtable__status ap-dtable__status--back"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 13.5 4.5 9 9 4.5"/><path d="M4.5 9h10a5.5 5.5 0 0 1 0 11H11"/></svg>Refunded</span></td>
        </tr>
        <tr>
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10426" /></td>
          <th class="ap-dtable__id" scope="row">#10426</th>
          <td>Lakshmi Nair</td>
          <td class="ap-dtable__date"><time datetime="2026-09-27">27 Sep 2026</time></td>
          <td class="ap-dtable__num" data-value="24800">₹24,800.00</td>
          <td><span class="ap-dtable__status ap-dtable__status--done"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8.2 12.4 2.6 2.6 5-5.2"/></svg>Delivered</span></td>
        </tr>
        <tr>
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10425" /></td>
          <th class="ap-dtable__id" scope="row">#10425</th>
          <td>Vikram Joshi</td>
          <td class="ap-dtable__date"><time datetime="2026-09-25">25 Sep 2026</time></td>
          <td class="ap-dtable__num" data-value="2150">₹2,150.00</td>
          <td><span class="ap-dtable__status ap-dtable__status--ship"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.5 6.5h11v9h-11z"/><path d="M13.5 9.5h4l3 3.2v2.8h-7"/><circle cx="6.5" cy="17.5" r="1.8"/><circle cx="16.5" cy="17.5" r="1.8"/></svg>Shipped</span></td>
        </tr>
        <tr>
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10424" /></td>
          <th class="ap-dtable__id" scope="row">#10424</th>
          <td>Priya Kapoor</td>
          <td class="ap-dtable__date"><time datetime="2026-09-24">24 Sep 2026</time></td>
          <td class="ap-dtable__num" data-value="7320">₹7,320.00</td>
          <td><span class="ap-dtable__status ap-dtable__status--done"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8.2 12.4 2.6 2.6 5-5.2"/></svg>Delivered</span></td>
        </tr>
        <tr>
          <td class="ap-dtable__pick"><input class="ap-dtable__box" type="checkbox" aria-label="Select order #10423" /></td>
          <th class="ap-dtable__id" scope="row">#10423</th>
          <td>Nikhil Verma</td>
          <td class="ap-dtable__date"><time datetime="2026-09-22">22 Sep 2026</time></td>
          <td class="ap-dtable__num" data-value="640">₹640.00</td>
          <td><span class="ap-dtable__status ap-dtable__status--done"><svg class="ap-dtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8.2 12.4 2.6 2.6 5-5.2"/></svg>Delivered</span></td>
        </tr>
      </tbody>
    </table>
  </div>
  <div class="ap-dtable__foot">
    <div class="ap-dtable__picked">
      <p class="ap-dtable__count" role="status" data-ap-picked><span class="ap-dtable__n" translate="no">2</span> <span>selected</span></p>
      <button type="button" class="ap-btn ap-btn--ghost ap-dtable__clear" aria-disabled="false" data-ap-deselect>Clear selection</button>
    </div>
    <p class="ap-dtable__sorted" role="status" data-ap-sorted><span>Sorted by</span> <span>Date,</span> <span>descending</span></p>
  </div>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    The caption names the table, th scope=col and scope=row tie every value to its column and its order number, and aria-sort marks the column that sets the order.

  • 1.4.10 Reflow Level AA

    At narrow widths the table scrolls inside its own region, so the page never scrolls sideways; data tables are an allowed exception for two-way scrolling.

  • 2.1.1 Keyboard Level A

    Sorting uses buttons and selection uses native checkboxes, and the scroll area takes focus so the arrow keys can scroll it.

  • 2.4.7 Focus Visible Level AA

    Sort buttons, checkboxes and the scroll area all show a two-pixel focus ring.

  • 2.4.11 Focus Not Obscured (Minimum) Level AA

    scroll-padding on the scroll area keeps a focused checkbox clear of the sticky header row and the sticky first columns.

  • 4.1.2 Name, Role, Value Level A

    Each row's checkbox is named for its order, Select all reports partly checked, and the sorted header carries aria-sort.

  • 4.1.3 Status Messages Level AA

    The new sort order and the number of selected rows are status messages, announced without moving focus.

Usage

When to use it

Use it

  • Records people compare on the same fields, like orders, invoices or members.
  • Lists long enough that changing the order helps people find a row.
  • Rows people act on in bulk, such as downloading or archiving several at once.

Use something else

  • Page layout: a table used for positioning reads as data to screen readers. Use CSS grid.
  • Editing values cell by cell with the arrow keys: use a data grid.
  • The details of one record: use a description list.

Common failures

How it usually goes wrong

  • Sort controls that are not buttons

    A clickable th or a lone arrow icon cannot be reached with Tab and says nothing when used. A button inside the header can, and the header stays a header.

  • aria-sort left on several headers

    Leaving the previous column marked, or setting none on every column, makes screen readers report more than one order. Only the column that sets the order carries aria-sort here.

  • Order shown only by an arrow

    An arrow is not announced and is easy to miss. aria-sort on the header and a status message say the order in words.

  • Checkboxes all called Select row

    Ten identical names are impossible to tell apart in a list of form controls. Each checkbox here names its order.

  • A sticky header over the focused row

    Tab can scroll a row's checkbox to the top edge, right under the sticky header. scroll-padding keeps whatever has focus in view.

  • A scroll area keyboards cannot scroll

    An overflowing box with no tab stop leaves keyboard users stuck at its left edge. This one is focusable and labelled while it scrolls.

Notes

Building it

  • Put a button inside each sortable th, and move aria-sort to the header that was used; remove it from the others rather than setting none.
  • Cells sort by data-value or a time element's datetime when the text people read is not the sort key, as with amounts and dates.
  • Select all's mixed state is the indeterminate property, which only script can set; without JavaScript it starts clear.
  • Don't put aria-selected on rows: it belongs to grids. In a table the checkbox carries the selected state, and the row's tint and bar only echo it.
  • The script makes the scroll area a tab stop only while it overflows, so a table that fits does not add an empty stop.

Sources: WAI-ARIA Authoring Practices: Sortable Table Example · WAI Tutorials: Tables · Adrian Roselli: Under-Engineered Responsive Tables

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction