HTML table with sort and filter

Sorting is one comparator and a re-append. Filtering is one input and a loop. Forty lines of plain JavaScript covers most tables, and the page stays a single file you can send as a link.

An HTML table with sort and filter needs no library. Click-to-sort headers and a search box are about forty lines of plain JavaScript in the same file as the table.

A table with a search input above it and arrow markers on two of the column headings.
A table with a search input above it and arrow markers on two of the column headings.

Below is the whole thing, split into the two behaviours. Both operate on the rows already in the document, so there is no data layer to keep in sync.

Sorting on a header click

Read the rows into an array, sort them, put them back.

<table id="grid">
  <thead>
    <tr>
      <th data-sort="text">Client</th>
      <th data-sort="num">Invoices</th>
      <th data-sort="date">Last order</th>
    </tr>
  </thead>
  <tbody>
    <tr><td>Aldridge</td><td data-value="14">14</td><td data-value="2026-03-04">4 Mar 2026</td></tr>
  </tbody>
</table>
<script>
const grid = document.getElementById('grid');
const body = grid.tBodies[0];
let dir = 1, last = -1;

grid.tHead.addEventListener('click', (e) => {
  const th = e.target.closest('th[data-sort]');
  if (!th) return;
  const i = [...th.parentNode.children].indexOf(th);
  dir = (i === last) ? -dir : 1;
  last = i;
  const type = th.dataset.sort;
  const rows = [...body.rows];
  rows.sort((a, b) => {
    const x = a.cells[i].dataset.value ?? a.cells[i].textContent.trim();
    const y = b.cells[i].dataset.value ?? b.cells[i].textContent.trim();
    if (type === 'num') return (Number(x) - Number(y)) * dir;
    return x.localeCompare(y) * dir;
  });
  rows.forEach((r) => body.appendChild(r));
});
</script>

appendChild on a row already in the document moves it rather than copying it, which is why no removal step is needed.

Why sorting goes wrong

Symptom Cause Fix
10 sorts before 9 Compared as text Number() the value first
Currency sorts randomly "$1,204" is not a number Put the raw number in data-value
Dates sort by day name Comparing the display string Put an ISO date in data-value
Blank rows go first Empty string sorts low Handle empties explicitly
Accented names out of order Byte comparison Use localeCompare

The pattern behind four of those five rows is the same. Sort the value, display the format, and keep them in separate places.

Filtering as you type

One input and one loop over the rows.

<input id="q" type="search" placeholder="Filter rows">
<script>
document.getElementById('q').addEventListener('input', (e) => {
  const q = e.target.value.toLowerCase();
  for (const row of body.rows) {
    row.hidden = q !== '' && !row.textContent.toLowerCase().includes(q);
  }
});
</script>

Using the hidden property rather than a style keeps the row in the document, so sorting still sees it and clearing the box restores everything.

For tables over a few thousand rows, cache each row's lowercased text once instead of reading textContent on every keystroke.

The table after typing into the search box. Non matching rows are hidden and the count line updates.
The table after typing into the search box. Non matching rows are hidden and the count line updates.

Details that make it usable

  • Show the sort direction. An arrow in the header, set with a class, so the reader knows which column is ordering the table.
  • Show the count. "18 of 240 rows" under the filter box. Without it, a filter that matched nothing looks like a broken table.
  • Keep the keyboard working. Use <button> inside the <th> rather than a click handler on the cell, so the header is reachable by tab.
  • Announce the change. aria-sort="ascending" on the active header tells a screen reader what happened.
  • Keep striping correct. Zebra rules based on nth-child restripe automatically after a sort, since the rows really moved. See zebra striping.

Filtering by column rather than by text

A single search box matches anything in the row, which is usually what people want. Column filters are the next step up and cost one more attribute.

<select id="status-filter">
  <option value="">All statuses</option>
  <option>Paid</option>
  <option>Overdue</option>
</select>

Match the selected value against one cell rather than the whole row, and combine it with the text filter so both conditions apply at once.

Keep the two filters in one function that decides each row's visibility. Two independent handlers, each setting hidden, will fight and the second one applied wins.

A status dropdown above the table with the text search box, both narrowing the visible rows.
A status dropdown above the table with the text search box, both narrowing the visible rows.

When a library is the right call

Plain JavaScript stops being the cheaper option at a fairly clear line.

  1. Tens of thousands of rows. You need virtual scrolling, and writing that yourself is a project.
  2. Server side data. Sorting has to become a request, not a comparator.
  3. Pagination, column resizing, grouping, export. A grid component already has these and they interact.
  4. Editable cells with validation. See the editable HTML table tool for the lighter version of this.

Below that line, forty lines you can read beats a dependency you cannot.

Keeping the behaviour when you share the page

Sorting and filtering live in the script, and a script in a separate file next to the page does not travel with it. Attach the page to an email and the table arrives inert.

Inline the JavaScript in a <script> block inside the same file. Self-contained HTML covers folding in styles, scripts and images.

The sortable table opened in a plain browser window. Clicking a heading still reorders the rows.
The sortable table opened in a plain browser window. Clicking a heading still reorders the rows.

Check it in the HTML file opener, which has never seen your project. If sorting works there, the script is genuinely inside the file.

Sending a table people can actually sort

Sorting and filtering exist to be used, so a screenshot or a PDF removes the reason the table was built. The reader needs the live page.

Paste the HTML into a NOS document. It renders as written, scripts included, at an address of its own. Share, then Share link, then Create link gives an unlisted link that opens in one click.

Correct a figure later by clicking the text in the document, and the address stays the same, so the link you sent already points at the fix. HTML to link is the same route for any page.

Questions people ask

Do I need a library to sort an HTML table?

No. Reading the rows into an array, sorting them with a comparator and appending them back in order is about fifteen lines. A library earns its place when you need pagination, column resizing, virtual scrolling for tens of thousands of rows, or server side data.

Why is my table sorting 10 before 9?

The values are being compared as text, and in text order 10 comes before 9. Parse the cell to a number before comparing, and use a data attribute to hold the raw value for anything formatted with currency symbols or thousands separators.

How do I sort dates correctly?

Do not sort the displayed date. Put a sortable value in a data attribute on the cell, such as data-sort="2026-03-04", and compare that. ISO dates sort correctly as plain strings, so no date parsing is needed.

Will sorting and filtering survive if I share the page?

Yes, as long as the script travels with the page. Keep the JavaScript inside the same file rather than in a separate .js file next to it, then the behaviour works wherever the page is opened.

Keep reading