How to Filter a Table with JavaScript

This recipe applies the same live-filtering technique to a table, checking each row's relevant cell instead of an entire list item.

Adapting list-filtering to table rows

A table adds one wrinkle that a plain list doesn't have: structure. A <li> is just a blob of text, but a table row is made of separate cells with different meanings — a name, an email, a price. Filtering "the whole row's text" would technically work, but it usually isn't what you want, because a search for a name shouldn't also match unrelated numbers in a price column. The more useful pattern is to filter each row by a specific cell, chosen by its column position, and hide or show the entire <tr> based on that one cell's match.

  • Select the rows to filter with document.querySelectorAll('tbody tr') — scoping to tbody keeps the header row out of the loop
  • For each row, grab the target cell with row.cells[index], where index is the zero-based column position
  • Lowercase the cell's textContent and compare it to the lowercased query with .includes(), exactly as with a list
  • Toggle display on the <tr> itself, not on the individual <td> — hiding one cell would misalign the rest of the row

Example

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Filter a Table</title>
<style>
  body {
    font-family: system-ui, sans-serif;
    padding: 40px;
    background: #f5f5f7;
  }

  input[type="search"] {
    width: 260px;
    padding: 8px 12px;
    font-size: 15px;
    border: 1px solid #ccc;
    border-radius: 6px;
    margin-bottom: 12px;
    box-sizing: border-box;
  }

  table {
    border-collapse: collapse;
    width: 100%;
    max-width: 480px;
    background: #fff;
  }

  th, td {
    text-align: left;
    padding: 8px 12px;
    border-bottom: 1px solid #eee;
  }

  th {
    background: #f0f0f0;
  }
</style>
</head>
<body>

  <input type="search" id="nameFilter" placeholder="Search by name…" aria-label="Search by name">

  <table>
    <thead>
      <tr>
        <th>Name</th>
        <th>Email</th>
        <th>Role</th>
      </tr>
    </thead>
    <tbody>
      <tr><td>Amara Diallo</td><td>amara@example.com</td><td>Designer</td></tr>
      <tr><td>Ben Cohen</td><td>ben@example.com</td><td>Developer</td></tr>
      <tr><td>Chen Wei</td><td>chen@example.com</td><td>Manager</td></tr>
      <tr><td>Diego Ramos</td><td>diego@example.com</td><td>Developer</td></tr>
      <tr><td>Emma Novak</td><td>emma@example.com</td><td>Designer</td></tr>
    </tbody>
  </table>

  <script>
    const input = document.getElementById('nameFilter');
    const rows = document.querySelectorAll('table tbody tr');

    input.addEventListener('input', () => {
      const query = input.value.trim().toLowerCase();

      rows.forEach((row) => {
        const nameCell = row.cells[0];
        const matches = nameCell.textContent.toLowerCase().includes(query);
        row.style.display = matches ? '' : 'none';
      });
    });
  </script>

</body>
</html>

When restoring a hidden row, set row.style.display = '' rather than hardcoding row.style.display = 'table-row'. Clearing the inline style lets the browser fall back to whatever the element's default or stylesheet-defined display value actually is — for a <tr> that's normally table-row, but if a stylesheet elsewhere overrides it, hardcoding the value fights that override instead of cooperating with it.

Note: This is a common gotcha when developers copy list-filtering code into a table: writing display = 'block' or display = 'table-row' explicitly works today but breaks the moment the table's CSS changes. An empty string is the safer default in almost every filtering scenario, for lists and tables alike.

Example

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Filter a Table by Chosen Column</title>
<style>
  body {
    font-family: system-ui, sans-serif;
    padding: 40px;
    background: #f5f5f7;
  }

  .controls {
    display: flex;
    gap: 8px;
    margin-bottom: 12px;
  }

  input[type="search"], select {
    padding: 8px 12px;
    font-size: 15px;
    border: 1px solid #ccc;
    border-radius: 6px;
    box-sizing: border-box;
  }

  table {
    border-collapse: collapse;
    width: 100%;
    max-width: 480px;
    background: #fff;
  }

  th, td {
    text-align: left;
    padding: 8px 12px;
    border-bottom: 1px solid #eee;
  }

  th {
    background: #f0f0f0;
  }
</style>
</head>
<body>

  <div class="controls">
    <select id="columnSelect" aria-label="Column to search">
      <option value="0">Name</option>
      <option value="1">Email</option>
    </select>
    <input type="search" id="tableFilter" placeholder="Type to filter…" aria-label="Filter value">
  </div>

  <table>
    <thead>
      <tr>
        <th>Name</th>
        <th>Email</th>
        <th>Role</th>
      </tr>
    </thead>
    <tbody>
      <tr><td>Amara Diallo</td><td>amara@example.com</td><td>Designer</td></tr>
      <tr><td>Ben Cohen</td><td>ben@example.com</td><td>Developer</td></tr>
      <tr><td>Chen Wei</td><td>chen@example.com</td><td>Manager</td></tr>
      <tr><td>Diego Ramos</td><td>diego@example.com</td><td>Developer</td></tr>
      <tr><td>Emma Novak</td><td>emma@example.com</td><td>Designer</td></tr>
    </tbody>
  </table>

  <script>
    const columnSelect = document.getElementById('columnSelect');
    const input = document.getElementById('tableFilter');
    const rows = document.querySelectorAll('table tbody tr');

    function applyFilter() {
      const columnIndex = Number(columnSelect.value);
      const query = input.value.trim().toLowerCase();

      rows.forEach((row) => {
        const cell = row.cells[columnIndex];
        const matches = cell.textContent.toLowerCase().includes(query);
        row.style.display = matches ? '' : 'none';
      });
    }

    input.addEventListener('input', applyFilter);
    columnSelect.addEventListener('change', applyFilter);
  </script>

</body>
</html>

Scaling up and avoiding gotchas

ColumnIndexData type
Name0text
Email1text
Role2text
  • Always scope your row selector to tbody — filtering table tr directly will try to hide the header row along with the data rows
  • For large tables (roughly 1000+ rows), consider caching each row's lowercased text once instead of recomputing it on every keystroke, or debounce the input handler
  • Show an empty-state row or message when zero rows match, so the table doesn't just look broken or empty with no explanation
Note: Keep the <thead> completely outside your filtering loop. It's easy to accidentally select it with a loose query selector and hide the column headers along with a non-matching row.

Once a table can be filtered by one column, extending it to search multiple columns, add per-column filters, or combine filtering with sorting is mostly a matter of repeating this same match-then-toggle logic per row — the underlying technique doesn't change.

Exercise: Filters

What's the typical way to filter a list of already-loaded items by category on the client side?

Frequently Asked Questions

How do I filter table rows with JavaScript?
Loop the <tbody> rows on each input event and set row.style.display to none when no cell matches the query. Skip the header row, or it disappears the moment someone types.
How do I search all columns of a table at once?
Join each row's cell text into a single string and test the query against that, rather than checking one column. row.textContent gives you the whole row's text in one step and matches across every column.