live search filter in vanilla JavaScript takes fewer than thirty lines of code, requires no third-party library, and works seamlessly inside any Bootstrap 5 project, including the Canvas HTML Template.
Key Takeaways
- A live search filter can be built with pure JavaScript — no jQuery or plugins required.
- Bootstrap 5 form controls style the search input with zero extra CSS.
- Filtering on
textContentis case-insensitive and covers every column simultaneously. - Small accessibility additions —
aria-label,role="status"live region — keep the feature WCAG-compliant. - The same pattern extends to card grids and list groups, not just
<table>elements.
Why Client-Side Filtering Is the Right Default
Server-side search is appropriate when datasets are large enough to make a full page load impractical — typically thousands of rows retrieved from a database. For most content tables embedded in a marketing or admin page, the data is already in the DOM. Sending a network request on every keystroke adds latency, burns server resources, and introduces failure modes you do not need. A client-side filter table javascript approach is instant, works offline, and is trivially simple to implement and maintain.
If your dataset eventually outgrows this approach, you can replace the filter function with a debounced fetch() call without touching the HTML structure at all.

Setting Up the Bootstrap 5 Table and Search Input
Start with a standard Bootstrap 5 table and a text input above it. The .table-responsive wrapper handles horizontal overflow on small viewports automatically.
<div class="mb-3">
<input
type="search"
id="tableSearch"
class="form-control"
placeholder="Search table…"
aria-label="Search table rows"
aria-controls="dataTable"
/>
</div>
<div class="table-responsive">
<table id="dataTable" class="table table-striped table-hover align-middle">
<thead class="table-dark">
<tr>
<th scope="col">#</th>
<th scope="col">Name</th>
<th scope="col">Department</th>
<th scope="col">Location</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody id="tableBody">
<tr>
<td>1</td>
<td>Alice Mercer</td>
<td>Engineering</td>
<td>London</td>
<td><span class="badge bg-success">Active</span></td>
</tr>
<tr>
<td>2</td>
<td>Ben Okafor</td>
<td>Marketing</td>
<td>Lagos</td>
<td><span class="badge bg-warning text-dark">On Leave</span></td>
</tr>
<tr>
<td>3</td>
<td>Clara Voss</td>
<td>Design</td>
<td>Berlin</td>
<td><span class="badge bg-success">Active</span></td>
</tr>
<!-- additional rows -->
</tbody>
</table>
</div>
<p id="searchStatus" role="status" aria-live="polite" class="text-muted small"></p>Note the aria-controls attribute on the input and the role="status" paragraph at the bottom. These are inexpensive additions that meaningfully improve screen-reader usability — a topic explored in depth in the post on web accessibility for Bootstrap 5 templates.
Writing the JavaScript Filter Function
The core logic is a single event listener on the input event. Iterating over <tbody> rows and toggling a d-none class keeps the implementation framework-agnostic and avoids DOM rebuilding.
<script>
(function () {
const searchInput = document.getElementById('tableSearch');
const tableBody = document.getElementById('tableBody');
const statusEl = document.getElementById('searchStatus');
searchInput.addEventListener('input', function () {
const query = this.value.trim().toLowerCase();
const rows = tableBody.querySelectorAll('tr');
let visibleCount = 0;
rows.forEach(function (row) {
const text = row.textContent.toLowerCase();
const match = text.includes(query);
row.classList.toggle('d-none', !match);
if (match) visibleCount++;
});
// Update live region for screen readers
if (query === '') {
statusEl.textContent = '';
} else {
statusEl.textContent =
visibleCount === 0
? 'No results found.'
: visibleCount + ' result' + (visibleCount !== 1 ? 's' : '') + ' found.';
}
});
})();
</script>Key decisions made here:
row.textContentcaptures every cell in one pass, so users can type any column value and get a match.d-noneis Bootstrap’s own utility class, so no extra CSS is needed to hide rows.- Wrapping everything in an IIFE prevents variable leakage into the global scope.
- The
role="status"live region announces match counts without interrupting the user’s typing flow.

Extending to Column-Specific Filtering
Searching all columns at once is the simplest UX. However, some tables benefit from per-column dropdowns — for example, filtering by “Department” independently of free-text search. The following snippet adds a <select> that works alongside the existing search input.
<div class="row g-2 mb-3">
<div class="col-sm-8">
<input type="search" id="tableSearch" class="form-control"
placeholder="Search…" aria-label="Search table rows" />
</div>
<div class="col-sm-4">
<select id="deptFilter" class="form-select" aria-label="Filter by department">
<option value="">All Departments</option>
<option value="engineering">Engineering</option>
<option value="marketing">Marketing</option>
<option value="design">Design</option>
</select>
</div>
</div><script>
(function () {
const searchInput = document.getElementById('tableSearch');
const deptFilter = document.getElementById('deptFilter');
const tableBody = document.getElementById('tableBody');
function applyFilters() {
const query = searchInput.value.trim().toLowerCase();
const dept = deptFilter.value.toLowerCase();
tableBody.querySelectorAll('tr').forEach(function (row) {
const text = row.textContent.toLowerCase();
// Column index 2 = Department (0-based)
const deptCell = row.cells[2]
? row.cells[2].textContent.toLowerCase()
: '';
const matchesQuery = query === '' || text.includes(query);
const matchesDept = dept === '' || deptCell.includes(dept);
row.classList.toggle('d-none', !(matchesQuery && matchesDept));
});
}
searchInput.addEventListener('input', applyFilters);
deptFilter.addEventListener('change', applyFilters);
})();
</script>This composable approach — one applyFilters function called by multiple event listeners — scales cleanly as you add more filter controls. The same compositional thinking applies when building filterable grids, as covered in the guide to filterable portfolio grids in Bootstrap 5.
Showing a “No Results” Message
Hiding all rows without explanation leaves users confused. Append a dedicated no-results row that only appears when every data row is hidden.
<!-- Place inside <tbody id="tableBody"> as the final child -->
<tr id="noResultsRow" class="d-none">
<td colspan="5" class="text-center text-muted py-4">
No matching records found.
</td>
</tr><script>
// Add inside applyFilters(), after the forEach loop:
const noResults = document.getElementById('noResultsRow');
const anyVisible = [...tableBody.querySelectorAll('tr')]
.filter(r => r.id !== 'noResultsRow')
.some(r => !r.classList.contains('d-none'));
noResults.classList.toggle('d-none', anyVisible);
</script>Performance, Debouncing, and Visual Polish
For tables with fewer than a few hundred rows, the input event fires fast enough that debouncing is unnecessary. Beyond that threshold, wrap the filter logic in a simple debounce to avoid redundant work on every keystroke.
<script>
function debounce(fn, delay) {
let timer;
return function () {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, arguments), delay);
};
}
searchInput.addEventListener('input', debounce(applyFilters, 150));
</script>For visual polish, consider adding a subtle CSS transition on the table rows so hidden rows fade rather than snap. Because Bootstrap’s d-none sets display: none, a CSS transition will not animate between states directly — use an opacity approach instead if animation matters for your project. Small interactions like these are what separate good templates from great ones, as explored in the post on microinteractions that make HTML templates feel premium.
Using This Pattern Inside Canvas Template Pages
If you are building on Canvas, drop the table markup into any section block and place the <script> before the closing </body> tag, after plugins.min.js and functions.bundle.js have already loaded. The Bootstrap 5 utility classes — table-striped, table-hover, d-none, form-control — are all available out of the box. You can also theme the search input border colour to match your brand by overriding the CSS custom property:
<style>
#tableSearch:focus {
border-color: var(--cnvs-themecolor);
box-shadow: 0 0 0 0.25rem rgba(var(--cnvs-themecolor-rgb), 0.25);
}
</style>This keeps the focus ring consistent with every other interactive element on the page without hardcoding a colour value.
Frequently Asked Questions
Yes, as long as the rows exist in the DOM at the time the user types. If rows are appended after the initial page load via a fetch() call or similar, the applyFilters function will include them on the next keystroke because it calls tableBody.querySelectorAll('tr') fresh every time. No re-initialisation is needed.
Yes. The IDs must be unique across the page, so if you have multiple filterable tables — including one inside a modal — give each table, input, and tbody its own distinct id. Each IIFE then references its own scoped elements with no cross-contamination.
The filter targets tableBody.querySelectorAll('tr'), and tableBody is the <tbody> element — not the entire table. The <thead> rows are entirely outside that scope, so they will never be toggled.
Yes, but it requires replacing the simple d-none toggle with logic that modifies each cell’s innerHTML to wrap matches in a <mark> tag. Be careful to strip any existing <mark> wrappers on each keystroke before re-applying them, and always sanitise the search query before inserting it into the DOM to prevent XSS.
At that scale, rendering 10,000 <tr> elements in the DOM is itself the bottleneck, regardless of filtering speed. Consider server-side pagination combined with a debounced API search, or a virtual-scroll library that renders only visible rows. The JavaScript filter function shown here is well-suited to datasets up to roughly 500–1,000 rows before noticeable lag appears on lower-powered devices.
Looking for a production-ready Bootstrap 5 HTML template? Browse Canvas Template demos and find the perfect starting point for your next project.
If you’re building with the Canvas HTML Template and want to ship production-ready Bootstrap 5 layouts faster, try Canvas Builder free — the visual builder that exports clean Canvas-ready markup in minutes.
Skip the setup — build it free
Spin up a complete Bootstrap 5 site, blog included, with Canvas Builder. No coding, no cost.
Canvas Team
Tutorials and tips for building beautiful Bootstrap 5 websites with the Canvas HTML Template and Canvas Builder.
More from the Canvas Blog