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| Title | Category | Due date |
|---|---|---|
| Redesign onboarding flow | Design | Aug 5, 2026 |
| Fix pagination bug on export | Engineering | Aug 8, 2026 |
| Write Q3 newsletter draft | Marketing | Jul 28, 2026 |
| Audit component color tokens | Design | Aug 15, 2026 |
| Migrate auth to new session store | Engineering | Aug 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.
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.
export const conditionFilterFn: FilterFn<any> = (row, columnId, filterValue) => {if (!filterValue?.operator) return trueconst 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.
if (operator.valueShape === 'none') return nullswitch (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.
const columnFilters = table.getState().columnFiltersreturn useMemo(() => {const conditions: FilterCondition[] = []for (const filter of columnFilters) {const meta = table.getColumn(filter.id)?.columnDef.metaif (!meta?.filterVariant) continueconst raw = filter.value as FilterConditionValue | undefinedif (!raw?.operator) continueconditions.push({ id: filter.id, columnId: filter.id, operator: raw.operator, value: raw.value })}return conditions}, [columnFilters, table])