HTML Formatterhtmlformatteronline.com

Sortable table

Click a column to sort, with aria-sort announced and no library.

tablesortjavascriptdataaccessibility
<table class="data" id="tbl">
  <caption>Top pages last month. Select a column heading to sort.</caption>
  <thead>
    <tr>
      <th scope="col" aria-sort="none"><button type="button">Page</button></th>
      <th scope="col" aria-sort="none" class="num" data-type="number"><button type="button">Views</button></th>
      <th scope="col" aria-sort="none" class="num" data-type="number"><button type="button">Avg. time</button></th>
      <th scope="col" aria-sort="none" class="num" data-type="number"><button type="button">Bounce</button></th>
      <th scope="col" aria-sort="none" data-type="date"><button type="button">Updated</button></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">/html-formatter/</th>
      <td class="num" data-value="18420">18,420</td>
      <td class="num" data-value="196">3m 16s</td>
      <td class="num" data-value="42.1">42.1%</td>
      <td data-value="2026-04-02"><time datetime="2026-04-02">2 Apr</time></td>
    </tr>
    <tr>
      <th scope="row">/json-formatter/</th>
      <td class="num" data-value="9315">9,315</td>
      <td class="num" data-value="241">4m 01s</td>
      <td class="num" data-value="35.8">35.8%</td>
      <td data-value="2026-03-28"><time datetime="2026-03-28">28 Mar</time></td>
    </tr>
    <tr>
      <th scope="row">/css-minifier/</th>
      <td class="num" data-value="4102">4,102</td>
      <td class="num" data-value="122">2m 02s</td>
      <td class="num" data-value="58.4">58.4%</td>
      <td data-value="2026-04-06"><time datetime="2026-04-06">6 Apr</time></td>
    </tr>
    <tr>
      <th scope="row">/base64-encoder/</th>
      <td class="num" data-value="2870">2,870</td>
      <td class="num" data-value="88">1m 28s</td>
      <td class="num" data-value="61.9">61.9%</td>
      <td data-value="2026-02-14"><time datetime="2026-02-14">14 Feb</time></td>
    </tr>
    <tr>
      <th scope="row">/templates/</th>
      <td class="num" data-value="12044">12,044</td>
      <td class="num" data-value="310">5m 10s</td>
      <td class="num" data-value="28.3">28.3%</td>
      <td data-value="2026-04-09"><time datetime="2026-04-09">9 Apr</time></td>
    </tr>
  </tbody>
</table>

<p class="live" id="live" role="status"></p>

How it works

The header is a button inside the th, not the th itself. A click handler on a table cell is unreachable by keyboard. A real <button> is focusable, activates on Enter and Space, and announces itself. The <th> keeps its scope and its aria-sort.

aria-sort is the whole accessibility story. It goes on the <th>, takes ascending, descending or none, and only one column may be sorted at a time. Screen readers announce it when the user lands on the column. It also drives the arrow's appearance, so the visual and announced states cannot disagree.

data-value separates display from sort key. "3m 16s" and "£402.50" and "2 Apr" are all unsortable as text. Putting the machine value in data-value lets you format the display however you like — and it is exactly what the datetime attribute already does for <time>.

appendChild moves nodes. Appending an element that is already in the document removes it from its old position first, so re-appending rows in sorted order reorders them without cloning anything. Event listeners on those rows survive.

localeCompare, not the < operator. A plain comparison sorts by code point, which puts "Zebra" before "apple" and mangles accented characters. localeCompare gets both right.

The status line matters more than it looks. Sorting reorders content silently. Without an announcement, a screen reader user presses the button and nothing appears to happen.

Accessibility notes

Only one column carries a sort state at a time. Leaving aria-sort set on a previously sorted column is the most common bug in sortable tables, and the loop above clears them all before setting the new one.

The status region uses role="status", which is polite — it waits for a pause rather than interrupting.

The arrow is a pseudo-element, so it adds nothing to the accessible name. The sort state comes from aria-sort, which is where it belongs.

Making it yours

For hundreds of rows, sort on the server or in a worker — reordering thousands of DOM nodes will drop frames. Under about five hundred rows this is fine.

To sort on load, call sortBy(1, "descending") and set the matching aria-sort on that header.

Add a text filter above the table and hide non-matching rows with hidden. Keep the sort and filter independent so applying one does not reset the other.

Related templates

Check your version

Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.