Filtering & Search
Built-in search, column filters, summary controls, and column visibility via the SmartFilterBar — plus an external search field wired through the table's view.
Smart Filter Bar
Enable enableSmartFilter to get a full-featured toolbar
with search, per-column filters, grouping controls, summary aggregations, and a column visibility
menu.
Smart Filter Bar
Name | Role | Department | Location |
|---|---|---|---|
Emma Wilson | Staff Engineer | Platform | Berlin |
Liam Chen | Product Designer | Design | Hamburg |
Sofia Martinez | Engineering Manager | Platform | Munich |
James Park | Frontend Developer | Product | Remote |
Aisha Patel | Data Scientist | Data | Berlin |
Noah Kim | DevOps Engineer | Platform | Hamburg |
<Table
{items}
{columns}
enableSmartFilter={true}
searchPlaceholder="Search employees..."
searchDebounceMs={300}
viewDefaults={{ pageSize: 6 }}
/>Narrow Bar & Tools Sheet
The bar's tools — filters, sort, grouping, summaries and column visibility — normally sit in a capsule beside the search field. Below 28rem (448px) of bar content box that capsule is gone: the tools move into a bottom sheet reached from a single button. Nothing is taken away, only relocated — each tool becomes a section of the sheet, rebuilt as a form instead of a menu. Sort, for instance, splits into a column list plus a separate direction control rather than offering every column×direction pair; summaries become one aggregation choice per summable column, which is why this demo declares two numeric columns.
There are up to five of them, not always five,
and the sheet mirrors the capsule exactly: grouping drops out while the table is virtualized, column visibility when enableColumnVisibility is off. Three is the floor. The
demo below has all five.
Drag the slider to squeeze the container past the threshold, then open the sheet. The switch is automatic — there is no prop for it. The readout is measured off the live bar rather than computed from the slider, so if the page is too narrow to give the demo the width it asks for, it says so instead of printing a number the bar never had. Note also that the sheet is a bottom Drawer, so it spans the window, not the bar: here a 360px table opens a full-width sheet. That gap closes when the bar is roughly the width of the window, which is the common phone case — but a bar inside a card is narrower than the sheet on a phone too.
Name | Department | Salary | Projects |
|---|---|---|---|
Sofia Martinez | Platform | 165000 | 15 |
Alexander Novak | Data | 148000 | 7 |
Emma Wilson | Platform | 142000 | 12 |
Isabella Singh | Design | 140000 | 18 |
Mia Zhang | Product | 135000 | 14 |
The width is measured on the bar, not on the window. A filter bar can sit in a card, a drawer, a split pane or a dashboard tile, so a viewport media
query would happily leave a 400px bar in the wide layout it has no room for. The bar observes
its own content box with a ResizeObserver instead — the box a @container query measures. Reading clientWidth would include the bar's own padding and
leave a dead band (24px at size="md") where neither
this switch nor the row/stack one fires.
There is only one threshold, and CSS owns it. The
same 28rem step the bar's @container rules use for the stacked/row switch also
decides this one — it sets a custom property, and the component reads which side of it the
bar is on. So the capsule can never be left standing in a layout too narrow to hold it, at
any root font size. It could before: the tool switch compared a hardcoded 448px while @container resolved 28rem against the root, so raising the browser's text size
opened a band where the bar had already stacked and the capsule was still there.
Only layout="responsive" — the default — switches. horizontal and vertical are explicit instructions from the consumer and
are left alone at any width. Growing back past the threshold closes the sheet, so a bar that is
narrowed again does not re-open it unprompted.
The badge on the button counts tools, not results. With the sheet shut, the lit triggers inside it are invisible, so the button carries the number
of things currently acting on the grid: one each for active filters (however many), a sort column,
a grouping, a summary row that is switched on, and hidden columns — at most five. The demo starts
at 1 because its viewDefaults seed a sort; add a filter or a grouping in
the sheet and watch it climb. Hidden columns count too — a column that is not on screen changes
what the reader sees just as much as a filter does.
Reproducing It
<!-- The bar measures its own CONTENT box, so the wrapper has to be
narrower than 28rem plus the bar's padding and border (~474px at
size="md") — max-w-md is exactly 28rem and still trips the switch.
Nothing is opted into here; the bar decides by itself. -->
<div class="max-w-sm">
<Table {items} {columns} enableSmartFilter />
</div>Filter Operators
Per-column filters are added through the filter button in the SmartFilterBar. Each filter is
a plain object — { column, operator, value } — and every active filter must match for a row to stay visible (AND semantics). value is always a string, even for the comparing
operators — the comparison converts internally, which keeps filters serializable for
persistence. Which operators the menu offers is driven by the column's dataType; columns with searchable: false (and synthetic columns without an accessor)
do not appear in the filter menu at all.
| Operator | Matches | Offered for |
|---|---|---|
contains | Substring, case-insensitive | text (the default) |
equals | The whole stringified value, case-insensitive | text, number, date (labelled “on date”) |
startsWith | Prefix, case-insensitive | text |
endsWith | Suffix, case-insensitive | text |
greaterThan | Numbers when both sides convert, instants otherwise | number, date (labelled “after”) |
lessThan | Numbers when both sides convert, instants otherwise | number, date (labelled “before”) |
Comparing operators resolve in two steps. If the cell value and the filter value both convert via Number(), they are compared as numbers. Otherwise
both sides are read as instants — Date objects, numbers (epoch milliseconds) and
ISO-8601 strings (2021-03-15, 2021-03-15T09:00, 2021-03-15T09:00:00Z). Any other string format never
matches, so a malformed or empty value filters everything out instead of matching
everything.
A date column's filter input emits a bare calendar
date (YYYY-MM-DD), and for that shape after/before compare on UTC day boundaries: "after 2021-03-15" starts at
the following midnight UTC and "before 2021-03-15" ends at that day's midnight UTC — a row
stamped 2021-03-15T09:00Z matches neither. A filter
value that carries a time of day compares instants strictly. Since a date-only string parses
as UTC midnight while a date-time string without an offset parses as local time, a Date built from local parts (new Date(2021, 2, 15)) can fall into the neighbouring UTC day — store ISO strings or UTC-constructed dates for
day-exact filtering.
To start with filters active, pass viewDefaults={{ filters: […] }} — an array of the same { column, operator, value } objects. That
is the view's baseline: the chips show them, users can still remove or add filters, and bindViewToStorage(view, { key }) applies a stored
set over it after hydration.
Both search and filters match against the column accessor's output — not against what a
custom cell renders. With source={{ processing: 'server', … }} the table does not filter locally: the active filters arrive as filters on the view your backend is given — see Server Processing.
Search from Outside
The search term is one of the six settings of the table's view. Hand the table a view of
your own — createTableView() — and the setting is
yours: an effect pushes your field into view.search,
and reading it back is just view.search, with no
callback in between. The table's own search field writes the same setting, so the readout
below shows what the table is filtering by, whichever of the two was typed into last.
One thing comes with writing a field directly: it does not reset the page — the table's own
handlers do that on a new search, so write view.page = 1 alongside if you want the same behaviour.
Writing a setting does not subscribe the effect to it, so the effect above runs when your field
changes and not when the table writes the same setting.
Steering a setting from outside and persisting the view are independent. A setting whose
value comes from outside is usually one you do not want stored — name the others instead: bindViewToStorage(view, { key: 'employees', axes: ['sort', 'filters', 'pageSize',
'groupBy'] }).
External Search Field
view.search, the table's own search field writes the same setting, and the readout reads it straight back.The table is searching for: —
Name | Role | Department | Location |
|---|---|---|---|
Emma Wilson | Staff Engineer | Platform | Berlin |
Liam Chen | Product Designer | Design | Hamburg |
Sofia Martinez | Engineering Manager | Platform | Munich |
James Park | Frontend Developer | Product | Remote |
Aisha Patel | Data Scientist | Data | Berlin |
Noah Kim | DevOps Engineer | Platform | Hamburg |
<script>
import { Table, createTableView } from '@urbicon-ui/table';
const view = createTableView({ defaults: { pageSize: 6 } });
let term = $state('');
// Outside → view. Writing a setting never subscribes the effect to it, so
// the table's own search field keeps working alongside this one.
$effect(() => {
view.search = term;
view.page = 1; // a direct field write does not reset the page
});
</script>
<Input bind:value={term} label="Search from outside the table" clearable />
<p>The table is searching for: <code>{view.search || '—'}</code></p>
<Table {items} {columns} {view} />