Search docs

Jump to any table example

Discord
ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

Filter Toolbar

An operator-based filter builder, Notion/tablecn-style. "+ Add filter" walks column → operator → value; each active condition becomes an editable pill; one generic filterFn evaluates every column by reading the operator off its condition instead of a fixed per-variant comparison.

npx shadcn add https://shad-table.dev/r/filter-toolbar-table.json
TitleCategoryDue date
Redesign onboarding flowDesignAug 5, 2026
Fix pagination bug on exportEngineeringAug 8, 2026
Write Q3 newsletter draftMarketingJul 28, 2026
Audit component color tokensDesignAug 15, 2026
Migrate auth to new session storeEngineeringAug 20, 2026

How it works

1.Operators — and the value shape each one needs — live in one registry

operatorRegistry maps each filterVariant to its valid operators (multiSelect gets "is any of" / "is none of", dateRange gets "is between", ...), and each operator declares a valueShape: none, single, array, or range. Neither the builder UI nor the filterFn hardcode a per-column operator list — both read it from here.

src/components/filter-toolbar/operators.ts
export const operatorRegistry: Record<FilterVariant, OperatorDef[]> = {
multiSelect: [
{ value: 'isAnyOf', label: 'is any of', valueShape: 'array' },
{ value: 'isNoneOf', label: 'is none of', valueShape: 'array' },
{ value: 'isEmpty', label: 'is empty', valueShape: 'none' },
{ value: 'isNotEmpty', label: 'is not empty', valueShape: 'none' },
],
dateRange: [
{ value: 'isBetween', label: 'is between', valueShape: 'range' },
{ value: 'isEmpty', label: 'is empty', valueShape: 'none' },
{ value: 'isNotEmpty', label: 'is not empty', valueShape: 'none' },
],
// ...
}

2.A condition is { columnId, operator, value } — one generic filterFn evaluates all of them

Every filterable column shares the same conditionFilterFn. The comparison it runs is picked by filterValue.operator (contains, isAnyOf, isBetween, ...), not by which column the filterFn is attached to — so adding a new variant only means adding operators to the registry and a case to evaluateOperator, not a new filterFn per column.

src/components/filter-toolbar/filter-fns.ts
export const conditionFilterFn: FilterFn<any> = (row, columnId, filterValue) => {
if (!filterValue?.operator) return true
const cellValue = row.getValue(columnId)
return evaluateOperator(filterValue.operator, cellValue, filterValue.value)
}
conditionFilterFn.autoRemove = (value) => !value?.operator

3.The builder walks column → operator → value, reusing existing controls

FilterConditionEditor is one component used for both adding a new filter and editing an existing one: pick a column (skipped when editing), pick an operator for that column's variant, then FilterValueInput renders whichever control matches the operator's valueShape — reusing MultiSelectFilter and DateRangeFilter as-is rather than rebuilding them.

src/components/filter-toolbar/filter-value-input.tsx
if (operator.valueShape === 'none') return null
switch (variant) {
case 'multiSelect':
return <MultiSelectFilter title={label} options={options ?? []} selected={value ?? []} onChange={onChange} />
case 'dateRange':
return <DateRangeFilter label={label} value={value} onChange={onChange} />
// text / number / select render Input or Select
}

4.Conditions, pills, and chips all read the same columnFilters — nothing is stored twice

useFilterConditions maps table.getState().columnFilters back into FilterCondition[] for columns with a filterVariant. The toolbar pills, "+ Add filter" menu, and ActiveFilterChips (via useActiveFilters) all derive from that single call — removing a condition just calls column.setFilterValue(undefined) and every view updates on the next render.

src/components/filter-toolbar/use-filter-conditions.ts
const columnFilters = table.getState().columnFilters
return useMemo(() => {
const conditions: FilterCondition[] = []
for (const filter of columnFilters) {
const meta = table.getColumn(filter.id)?.columnDef.meta
if (!meta?.filterVariant) continue
const raw = filter.value as FilterConditionValue | undefined
if (!raw?.operator) continue
conditions.push({ id: filter.id, columnId: filter.id, operator: raw.operator, value: raw.value })
}
return conditions
}, [columnFilters, table])