Users don’t scroll through 500 rows. They search.
Knowing how to filter a table with JavaScript is one of those practical skills that shows up constantly in real projects, whether you’re building an admin dashboard, a product listing, or a data-heavy internal tool.
This guide covers everything from basic real-time table search with a text input, to dropdown filtering, multi-criteria logic, sorting integration, and performance handling for large datasets.
You’ll also find framework-specific patterns for React and Vue, common mistakes worth avoiding, and accessibility requirements that most tutorials skip entirely.
Table of Contents
What Is Table Filtering in JavaScript

Table filtering in JavaScript is the process of showing or hiding <tr> elements in an HTML table based on user input or defined conditions, without reloading the page.
It runs entirely on the client side. The DOM stays intact; rows just get toggled between visible and hidden depending on what the user types or selects.
This is different from server-side filtering, where a new request goes to the backend each time the user changes a filter. Client-side filtering is faster for small to medium datasets because the data is already loaded in the browser.
Key distinction:
| Approach | How it works | Best for |
|---|---|---|
| Client-side | Hides/shows rows via JavaScript | Small to medium datasets, quick UI |
| Server-side | New request to backend per filter | Large datasets, sensitive data |
JavaScript is used by 98.8% of websites as of 2024 (Stack Overflow Developer Survey), making it the default language for this kind of DOM interaction.
How the DOM Structure Affects Filtering Logic
The filtering logic you write depends directly on how the HTML table is structured.
A standard table has <table> > <thead> > <tr> for headers, and <table> > <tbody> > <tr> for data rows. Your filter should only target rows inside <tbody>, not the header row.
Why this matters: If you select all <tr> elements without narrowing to <tbody>, you’ll accidentally hide the column headers during filtering. That’s one of those bugs that takes a while to spot.
| Element | Role in filtering |
|---|---|
<thead> |
Skip it – never target these rows |
<tbody> |
Your filter targets <tr> here |
<tr> |
The row you show or hide |
<td> |
The cell you check for matching text |
Column order also matters when filtering by a specific column. If you need to target the second column, you’ll access it using cells[1]. That index breaks if columns get reordered in the HTML.
For hiding rows, row.style.display = 'none' is the standard approach. It removes the row from the visible layout without touching the DOM structure. Using the hidden attribute works too, but display gives you more control and is easier to reset.
How to Filter a Table by Text Input
This is the most common use case. A user types in a search box, and matching rows stay visible while non-matching rows disappear in real time.
Here’s the full working pattern:
const input = document.getElementById('filterInput');
const rows = document.querySelectorAll('#myTable tbody tr');
input.addEventListener('input', function () {
const query = this.value.toLowerCase();
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(query) ? '' : 'none';
});
});
What each part does:
addEventListener('input', ...)fires on every keystroke, not just on submit.textContent.toLowerCase()handles case-insensitive matching without extra logicrow.style.display = ''resets the row to its default display state (not hardcoded toblockortable-row)
One thing worth noting: textContent grabs all text across every cell in the row. That means a search for “New York” will match a row even if “New” is in one column and “York” is in another. Whether that’s a problem depends on your data.
Filter by a Specific Column Only
Sometimes you only want to search within one column. Filtering a product table by name only, while ignoring price and category columns, is a good example.
input.addEventListener('input', function () {
const query = this.value.toLowerCase();
rows.forEach(row => {
const cell = row.cells[0]; // first column
const cellText = cell ? cell.textContent.toLowerCase() : '';
row.style.display = cellText.includes(query) ? '' : 'none';
});
});
row.cells[index] gives you direct access to a specific <td> by its position. Index 0 is the first column, index 1 is the second, and so on.
Always add a null check on cell before accessing .textContent. If a row has fewer cells than expected (malformed HTML), skipping the null check will throw an error and break the entire filter.
Filter Across All Columns With .some()
When you want to match any column but need cleaner logic than a flat textContent search, Array.from() with .some() is the right move.
rows.forEach(row => {
const matches = Array.from(row.cells).some(cell =>
cell.textContent.toLowerCase().includes(query)
);
row.style.display = matches ? '' : 'none';
});
This loops through each <td> individually. The moment one cell matches, .some() stops checking and marks the row as visible.
It’s more precise than row.textContent because it won’t create false positives from adjacent column text accidentally forming a match.
How to Filter a Table with a Dropdown Select
Dropdown filtering works differently from text input. Instead of matching partial strings, you’re matching an exact value against a specific column.
const select = document.getElementById('statusFilter');
select.addEventListener('change', function () {
const selected = this.value.toLowerCase();
rows.forEach(row => {
const cell = row.cells[2]; // "Status" column
const cellText = cell ? cell.textContent.toLowerCase().trim() : '';
const show = selected === '' || cellText === selected;
row.style.display = show ? '' : 'none';
});
});
Three things to get right here:
- Empty value handling – add a default “Show All” option with
value="". When selected, every row should appear. .trim()– cell content sometimes has leading/trailing whitespace. Without it,"active"won’t match" active".- Exact match vs. includes – dropdowns usually need
===, not.includes(). You want “Active” to match only “Active”, not “Inactive”.
A real-world example: a customer table filtered by subscription status (Trial, Active, Cancelled). The dropdown lets users quickly isolate one group without typing anything.
How to Filter a Table by Multiple Criteria Simultaneously

Combining a text input with a dropdown is where most real filtering UIs end up. The key is running a single filtering function that checks both conditions at once, rather than two separate loops.
function filterTable() {
const query = document.getElementById('filterInput').value.toLowerCase();
const selected = document.getElementById('statusFilter').value.toLowerCase();
rows.forEach(row => {
const nameCell = row.cells[0];
const statusCell = row.cells[2];
const nameMatch = nameCell
? nameCell.textContent.toLowerCase().includes(query)
: true;
const statusMatch = selected === ''
|| (statusCell && statusCell.textContent.toLowerCase().trim() === selected);
row.style.display = (nameMatch && statusMatch) ? '' : 'none';
});
}
document.getElementById('filterInput').addEventListener('input', filterTable);
document.getElementById('statusFilter').addEventListener('change', filterTable);
Both the input and the select trigger the same filterTable function. This avoids duplicating logic and keeps behavior consistent.
AND vs. OR logic:
- AND – a row must match all active filters. Most UIs want this.
- OR – a row matches if it satisfies any one filter. Useful for multi-select scenarios.
The example above uses AND logic. If you need OR, change nameMatch && statusMatch to nameMatch || statusMatch.
React (used by 39.5% of developers per Stack Overflow 2024) handles this pattern differently – you’d filter the data array before rendering, not manipulate the DOM directly. But the boolean logic stays the same regardless of which approach you use.
Your beautiful data deserves to be online
wpDataTables can make it that way. There’s a good reason why it’s the #1 WordPress plugin for creating responsive tables and charts.

And it’s really easy to do something like this:
- You provide the table data
- Configure and customize it
- Publish it in a post or page
And it’s not just pretty, but also practical. You can make large tables with up to millions of rows, or you can use advanced filters and search, or you can go wild and make it editable.
“Yeah, but I just like Excel too much and there’s nothing like that on websites”. Yeah, there is. You can use conditional formatting like in Excel or Google Sheets.
Did I tell you you can create charts too with your data? And that’s only a small part. There are lots of other features for you.
How Sorting and Filtering Work Together
Sorting and filtering are separate operations, but they need to play well together.
Filtering hides rows. Sorting reorders them. Neither operation destroys the other – but you need to run the filter again after a sort if you want the visible rows to stay correct.
The safe approach: store your sort and filter state in variables, then run one unified render() function that applies both.
let currentSort = { column: null, direction: 'asc' };
let currentFilter = { query: '', status: '' };
function render() {
let data = [...allRows]; // work with a copy
// Apply filter
data = data.filter(row => {
const name = row.cells[0].textContent.toLowerCase();
const status = row.cells[2].textContent.toLowerCase().trim();
const nameMatch = name.includes(currentFilter.query);
const statusMatch = !currentFilter.status || status === currentFilter.status;
return nameMatch && statusMatch;
});
// Apply sort
if (currentSort.column !== null) {
data.sort((a, b) => {
const aText = a.cells[currentSort.column].textContent;
const bText = b.cells[currentSort.column].textContent;
return currentSort.direction === 'asc'
? aText.localeCompare(bText)
: bText.localeCompare(aText);
});
}
// Re-render
const tbody = document.querySelector('#myTable tbody');
data.forEach(row => tbody.appendChild(row));
// Hide non-matching rows
allRows.forEach(row => {
row.style.display = data.includes(row) ? '' : 'none';
});
}
This is where JavaScript sorting tables logic connects to filtering. The two features share the same data loop and render step.
When vanilla JS gets complicated, libraries like DataTables handle sorting and filtering state automatically. That’s worth considering when you’re managing more than a few columns or expect frequent UI changes. For straightforward use cases, vanilla is fine and faster to ship.
| Scenario | Recommended approach |
|---|---|
| Simple table, 2-3 columns | Vanilla JS, separate event listeners |
| Multiple columns, sort + filter | Unified render function |
| Complex interactions, pagination | DataTables or AG Grid |
Filter Large Tables Without Slowing the Page
Vanilla JavaScript filtering on tables with hundreds of rows is fine. Past a thousand rows, you start noticing the lag.
The problem is usually the input event firing on every single keystroke, triggering a full DOM loop each time. A 300ms debounce fixes most of it.
function debounce(fn, delay) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), delay);
};
}
const filterTable = debounce(function () {
const query = document.getElementById('filterInput').value.toLowerCase();
rows.forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(query) ? '' : 'none';
});
}, 300);
document.getElementById('filterInput').addEventListener('input', filterTable);
300ms is the standard delay. Fast enough to feel responsive, slow enough to skip most intermediate keystrokes.
Beyond debouncing, cache your row references before the loop runs. Querying the DOM inside the loop is one of the most common performance mistakes in dynamic table search implementations.
// Do this ONCE outside the event listener
const rows = Array.from(document.querySelectorAll('#myTable tbody tr'));
This stores the NodeList as an array in memory. The filter loop then reads from that cached reference instead of re-querying the DOM on every keystroke.
When to Consider Virtual Scrolling
Virtual scrolling only renders the rows currently visible in the viewport, instead of all rows in the DOM at once.
With 50,000 rows in a plain HTML table, even browsers struggle. Each cell is a DOM node. At 50 visible rows times 10 columns, you get 500 active nodes instead of 500,000.
Libraries that handle this:
- AG Grid – used by large enterprises including Goldman Sachs and JPMorgan for financial data grids
- Tabulator.js – open-source, good documentation, handles progressive rendering
- Handsontable – popular for spreadsheet-like interactions with large row counts
A dev.to analysis from 2024 noted that even 1,000 rows can degrade user experience without pagination or virtual rendering, particularly on mid-range mobile devices.
Vanilla JS filtering is the right call for most use cases. If your table has more than 5,000 rows and users expect instant results, reach for a JavaScript table library built for that scale.
Filter a Table with JavaScript Frameworks
Vanilla JS directly toggles display on DOM nodes. Frameworks work differently. The data layer gets filtered first, then the filtered result determines what renders.
| Approach | Filter target | DOM update |
|---|---|---|
| Vanilla JS | DOM rows | style.display toggle |
| React | State array | Component re-render |
| Vue | Reactive data | Computed property |
The core logic is the same. What changes is where the filtering happens.
React Table Filtering Pattern
React doesn’t touch the DOM. You filter the data, and the component re-renders with only matching rows.
import { useState } from 'react';
function FilterableTable({ data }) {
const [query, setQuery] = useState('');
const filtered = data.filter(row =>
row.name.toLowerCase().includes(query.toLowerCase())
);
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<table>
<tbody>
{filtered.map(row => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.status}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
useState holds the search query as a reactive value. Every change re-runs the .filter() on the data array.
No rows are hidden. Non-matching rows are simply never rendered. That’s a meaningful difference from vanilla JS, especially when rows have complex child components.
Stack Overflow’s 2024 survey shows React is used by 39.5% of professional developers, making this pattern one of the most common in production table implementations today.
Vue Table Filtering Pattern
Vue uses computed properties for this. They cache their result until a reactive dependency changes, so filtering only re-runs when the query or data actually changes.
<script setup>
import { ref, computed } from 'vue'
const query = ref('')
const rows = ref([
{ id: 1, name: 'Alice', status: 'Active' },
{ id: 2, name: 'Bob', status: 'Inactive' },
])
const filteredRows = computed(() =>
rows.value.filter(row =>
row.name.toLowerCase().includes(query.value.toLowerCase())
)
)
</script>
<template>
<input v-model="query" placeholder="Search..." />
<table>
<tbody>
<tr v-for="row in filteredRows" :key="row.id">
<td>{{ row.name }}</td>
<td>{{ row.status }}</td>
</tr>
</tbody>
</table>
</template>
v-model binds the input directly to query. Vue automatically re-evaluates filteredRows when query changes, with no manual event listener needed.
CoreUI’s documentation notes that computed properties are also automatically cached, meaning filtering only recalculates when the underlying reactive data actually changes. For large data sets, that caching makes a measurable difference versus running a method on every render.
Vue holds 15.4% developer usage in the Stack Overflow 2024 survey, with particularly strong adoption among smaller teams and solo developers who value the lower setup cost.
Common Mistakes When Filtering Tables in JavaScript
Most filtering bugs come from one of a few predictable places. Knowing them ahead of time saves a frustrating debugging session.
The most common ones:
- Filtering
<thead>rows accidentally – always scope your row selection totbody tr, not justtr - Forgetting to reset
displayon all rows before applying a new filter (rows hidden by a previous search stay hidden) - Not handling empty input – when the search field is cleared, every row should reappear
- Case sensitivity bugs – always call
.toLowerCase()on both the cell text and the query - Losing filter state after a re-render – in React or Vue, filters stored in local variables outside of state will reset when the component re-renders
One pattern worth knowing: some developers hardcode row.style.display = 'table-row' to show a row, but that breaks if the original display value was something else (like flex or grid in a custom-styled table). Use row.style.display = '' to reset to the element’s default instead.
The reset bug is probably the most annoying in practice. Type “New York,” see the filtered results, then clear the input and half your rows are still gone. That’s because the filter ran with an empty string but the previous display: none values weren’t cleared first.
Always start the filter function by resetting all rows to visible, then apply the hide logic:
rows.forEach(row => {
row.style.display = ''; // reset first
});
rows.forEach(row => {
if (!row.textContent.toLowerCase().includes(query)) {
row.style.display = 'none';
}
});
Or, more compactly:
rows.forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(query) ? '' : 'none';
});
Both work. The second is cleaner. But make sure you understand what the first version makes explicit before collapsing it.
Accessible Table Filtering
95.9% of home pages had detectable WCAG failures as of the 2024 WebAIM Million report. Table filtering is one of the places where accessibility is most often skipped entirely.
The minimum requirement: screen readers need to know when filtering changes the visible content.
Announce Filter Results with aria-live
Without an aria-live region, a screen reader user types into the filter input and hears nothing. The table updates visually, but assistive technology has no way to announce the change.
<div aria-live="polite" aria-atomic="true" class="sr-only" id="filterStatus">
Showing 12 of 50 results
</div>
Update the text content of this element in JavaScript whenever the filter runs:
const status = document.getElementById('filterStatus');
const visibleCount = Array.from(rows).filter(r => r.style.display !== 'none').length;
status.textContent = `Showing ${visibleCount} of ${rows.length} results`;
aria-live="polite" queues the announcement without interrupting the user. Use "assertive" only if the result is time-sensitive or an error.
Keyboard Navigation and Focus
Filter inputs need to be reachable and operable by keyboard alone. That’s table stakes, not an enhancement.
- The search input must be in the natural tab order (don’t use
tabindex="-1"on it) - Dropdowns must respond to arrow keys and Enter
- If a filter clears all results, focus should not be lost or trapped
WCAG 2.2 became the W3C Recommendation in October 2023. The updated criteria include stricter focus visibility requirements (2.4.11, 2.4.13) that affect filter inputs specifically. Any interactive filter control must have a visible focus indicator with sufficient contrast.
For accessible tables, proper <th> elements with scope attributes are also part of the picture. They help screen readers associate filtered rows with the correct column headers, which matters even more when rows disappear and the table structure gets thinner.
JAWS holds approximately 53% of the enterprise screen reader market per WebAIM’s 2023 survey, so testing filter interactions with JAWS and NVDA covers the majority of screen reader users your table will encounter.
FAQ on How To Filter A Table With Javascript
How do I filter a table row in JavaScript?
Loop through all <tr> elements inside <tbody>, check each row’s textContent against your search query, then toggle row.style.display to show or hide it. Use .toLowerCase() on both values for case-insensitive matching.
How do I filter a table by a specific column?
Access the target cell using row.cells[index], where the index matches the column position. Check only that cell’s textContent instead of the full row. This keeps the filter precise and avoids false matches from unrelated columns.
How do I make a real-time search filter for an HTML table?
Attach an addEventListener('input', ...) to your search box. The filter function runs on every keystroke, immediately showing or hiding rows based on the current query. No button click or form submission needed.
What is the difference between client-side and server-side table filtering?
Client-side filtering hides and shows rows already loaded in the DOM. Server-side filtering sends a new request to the backend each time. Client-side is faster for small datasets; server-side handles large or sensitive data better.
How do I filter a table using a dropdown in JavaScript?
Listen for the change event on a <select> element. Compare the selected value against the target column’s textContent using strict equality (===). Always include a default empty option to reset and show all rows.
Can I filter a table by multiple columns at the same time?
Yes. Store each filter value in a variable, then run one unified function that checks all conditions per row using AND logic. A row only stays visible if it passes every active filter simultaneously.
How do I reset a table filter to show all rows?
Set row.style.display = '' on every row. This restores each row’s default display value without hardcoding table-row. Trigger this reset whenever the search input is cleared or the filter is removed.
How do I filter a large HTML table without performance issues?
Debounce the input event with a 300ms delay to avoid running the filter loop on every single keystroke. Also cache your row references in a variable before the loop instead of querying the DOM each time.
How do I filter a table in React?
Store the search query in useState. Use the JavaScript .filter() method on your data array to create a filtered copy, then render only that result. Non-matching rows are never rendered rather than hidden.
How do I make a filtered table accessible for screen readers?
Add an aria-live="polite" region that announces the number of visible results after each filter update. Make sure all filter inputs are keyboard-accessible and have visible focus indicators that meet WCAG 2.2 contrast requirements.
Conclusion
Understanding how to filter a table with JavaScript puts a genuinely useful tool in your hands, one that works across plain HTML tables, React components, and Vue applications without needing a library.
The core mechanics are straightforward: loop through <tbody> rows, compare textContent against a query, toggle display.
From there, the complexity scales with your needs. Debouncing handles performance. Multi-criteria logic handles real-world data. aria-live regions handle accessibility.
Pick the pattern that fits your dataset size and tech stack, and don’t overcomplicate it. Vanilla JavaScript is enough for most cases.
For larger tables with sorting, pagination, or framework-specific state management, the same filtering logic applies, just at a different layer of abstraction.



