Filterable Table — Per-Column Filter HTML CSS JS

Filterable Table · Tables · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Per-column filter inputs
A filter input in every header, each bound to its column index.
AND-combined matching
Rows must satisfy every active filter, so criteria stack to narrow results.
Text and numeric modes
Text columns use substring matching; the numeric column treats input as a minimum threshold.
Safe match highlighting
Matched substrings are wrapped in <mark> after HTML-escaping, so data can't inject markup.
Live result count
A "N of M" counter shows how much the filters have narrowed the table.
Empty state
A friendly message appears when no rows match instead of a blank table.
One-click clear
A Clear filters button resets every column input and re-renders.
Data-driven & no library
Renders from a ROWS array with generic column logic — zero dependencies.

About this UI Snippet

Filterable Table — Per-Column Header Filters with Live AND Matching & Highlighting

Screenshot of the Filterable Table snippet rendered live

A single global search box is fine for "find anything," but when users need to narrow a table by specific fields — department *and* location *and* salary — per-column filters are far more powerful. This snippet builds a table with a filter input in every column header, combining them with AND logic so each keystroke narrows the result, plus match highlighting, a numeric minimum filter, and a live count — all in plain HTML, CSS, and vanilla JavaScript with no library.

A filter per column, combined with AND

Each header carries its own input bound to a column index. As the user types in any of them, the matching value is stored in a filters array and the table re-renders, showing only rows that satisfy *every* active filter. This AND combination is the whole point: typing "Engineering" in Department and "Berlin" in Location shows only people who are both, which a single search box can't express. Empty filters are ignored, so the table starts unfiltered and narrows as criteria are added.

Text columns vs. the numeric column

Text columns use case-insensitive substring matching — the forgiving behaviour users expect from a filter. The salary column is treated differently: its filter is parsed as a *minimum*, so typing "100000" shows everyone earning at least that. Recognising that numbers want a threshold filter, not a substring match (you don't search a salary for the characters "100000"), is the detail that makes per-column filtering genuinely useful rather than naïve.

Match highlighting

When a text filter is active, the matching substring in each cell is wrapped in a <mark>, so the user sees exactly why a row matched. The highlighting is built safely: all cell text is HTML-escaped first, then only the matched slice is wrapped, so data containing < or & can never inject markup. This escape-then-wrap order is the safe way to do highlight rendering with innerHTML.

Live count, empty state, and clear

A counter shows "N of M" so the user always knows how much the filters have narrowed the set, and a friendly empty state appears when nothing matches rather than a blank table. A "Clear filters" button resets every input and filter in one click. These three touches — count, empty state, reset — are what separate a finished filterable table from a bare filter() demo.

Data-driven and drop-in

Rows come from a ROWS array, and the filtering logic is generic over columns, so adding a column is a markup-plus-data change, not a rewrite. Because it's dependency-free, it drops into any admin panel or report, and it's a clear reference for per-column AND filtering, safe highlight rendering, and mixed text/numeric matching.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Instead of tracing rowMatches and highlight by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the salary column is parsed as a numeric minimum instead of a substring match while the other three columns aren't, and why highlight() escapes the full cell text before wrapping only the matched slice in a mark tag. The same assistant is useful for optimizing it — ask whether re-filtering and re-joining the entire ROWS array into an HTML string on every keystroke would still be cheap with thousands of rows, or whether the input listeners should be debounced. It's just as good for extending the table: have it add a sortable header on top of the filters, a range filter (min and max) for salary instead of a minimum only, or persist the current filters into the URL query string so a filtered view is shareable. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a per-column filterable data table in plain HTML, CSS, and JavaScript — no libraries.

Requirements:
- A table with a filter text input embedded inside every column header (four columns: name, department, location, salary), each input tagged with its column index via a data attribute.
- Maintain a filters array in JS, one entry per column, updated on each input's input event; re-render the visible rows on every keystroke by filtering the full source data array against all currently active (non-empty) filters combined with AND logic — a row must satisfy every active filter simultaneously, not just one.
- The three text columns must use case-insensitive substring matching. The numeric salary column must be treated differently: strip non-numeric characters from the filter value, parse it as a number, and treat it as a minimum threshold — only show rows whose salary is greater than or equal to that number.
- For every text column with an active filter, wrap the matching substring of the cell's rendered text in a mark element to visually highlight the match, but first HTML-escape the entire cell value so the escape happens before the mark is inserted, guaranteeing user-controlled data can never break out into real markup.
- Show a live "N of M" count of visible rows versus total rows, and show a distinct empty-state message (not just a blank table) when zero rows match the current filters.
- Add a single "Clear filters" button that resets every filter input's value and the underlying filters array in one action and re-renders the full unfiltered table.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSAn employees table renders with a filter input in every column header and a row count.
  2. 2
    Filter by columnType in any header input — the table narrows live, combining all active filters with AND logic.
  3. 3
    Use the numeric filterType a number in the Salary filter to show only rows at or above that minimum.
  4. 4
    See highlights and countMatching text is highlighted, and the "N of M" count shows how much you've narrowed the set.
  5. 5
    Clear filtersClick Clear filters to reset every column input at once.
  6. 6
    Swap in your dataReplace the ROWS array (and headers) with your own columns; the filtering logic is generic.

Real-world uses

Common Use Cases

Admin and HR directories
Let staff narrow large people tables by department and location — pair with a data table for sorting and actions.
Product and inventory lists
Filter catalogs by multiple fields at once alongside a sortable table.
Reports and dashboards
Slice operational data per column next to a bar chart of the totals.
Log and event browsers
Narrow logs by level, source, and message with AND filters.
Order and ticket queues
Filter by status, owner, and amount, complementing a bulk actions bar.
Learning table filtering
A reference for per-column AND filtering and safe highlighting — compare with an expandable table.

Got questions?

Frequently Asked Questions

Each header input writes its value into a filters array keyed by column index, and a row is shown only if it satisfies every active filter (AND logic). Empty filters are skipped, so the table starts unfiltered and narrows as you add criteria. This lets users express "Engineering AND Berlin AND salary ≥ 100k" — something a single global search box cannot do.

Numbers want a threshold, not a substring match — you don't search a salary for the literal characters "100000". The salary filter is parsed to a number and used as a minimum, so typing 100000 shows everyone earning at least that. Text columns keep case-insensitive substring matching, which is the forgiving behaviour expected for names and categories.

Yes. Every cell's text is HTML-escaped first, and only then is the matched slice wrapped in <mark>. Because the escaping happens before any markup is added, data containing <, >, or & is rendered as text and can never inject elements — the correct escape-then-wrap order for highlight rendering with innerHTML.

Add a <th> with a filter input (set its data-col to the new index) and include the value in each ROWS entry. The rowMatches and render functions iterate columns generically, so text columns work automatically; for another numeric/threshold column, extend the numeric branch in rowMatches to that index.

In React, hold the rows and a filters object in useState, derive the visible rows with a useMemo over the filters, and render highlighted cells; in Vue, use a computed filtered list with v-for; in Angular, use a pipe or a getter with *ngFor. The matching and highlight logic is framework-agnostic — only the state and re-render move into the framework.