Data Filter

A composable filter builder over a consumer-owned useReactTable() instance — a single "+ Filter" trigger chains column → operator → value into a clickable, removable chip.

TitleStatusPriorityDone
Design onboarding flowIn Progress2No
Fix invoice rounding bugDone1Yes
Write Q3 reportTodo3No
Review PR #482In Progress1No

DataFilter is a headless-composition component over the same useReactTable() instance you'd pass to Data Grid — they aren't coupled, and a consumer can use either alone. They share only the table instance and the column.meta type contract from Data Grid Types. All eight column types have a working operator set here — text, number, currency, date, select, boolean, multiSelect, and user. user shares select's operators verbatim, with "Unassigned" as a pickable null value rather than a dedicated operator.

Installation

pnpm dlx shadcn@latest add @syncblocks/data-filter

Usage

import { DataFilter, dataGridFilterFn } from "@/components/ui/data-filter"
const columns: ColumnDef<Task>[] = [
  { accessorKey: "title", header: "Title", meta: { type: "text" }, filterFn: dataGridFilterFn },
  // ...
]

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
})

<DataFilter table={table} />

Every filterable column needs both a meta.type and filterFn: dataGridFilterFn — the type drives the operator set and value input this component renders, and dataGridFilterFn is what actually evaluates the { operator, value } object the chip chain writes via column.setFilterValue(). None of TanStack's own built-in filter functions can read that shape; they all expect the filter value directly. The table also needs getFilteredRowModel()DataFilter only writes filter state, it never filters rows itself.

Features

  • A single "+ Filter" trigger rather than a control per column, so the toolbar stays a constant size no matter how wide the table is. Filterable columns derive from meta.type (one of the six types above) plus column.getCanFilter() — excluding a column from the builder is an ordinary TanStack concern (enableColumnFilter: false), not a second config list.
  • Column → operator → value chain — the popover walks through picking a column, then an operator (from that column's type-specific set), then a value control shaped for the operator: a single input for most, two inputs for "is between", a searchable list for select is/is-not, and a searchable multi-select for "is any of". boolean ends at the operator — "is true"/"is false" commit immediately, with no value step at all.
  • Each committed filter renders as a [ column | operator | value | x ] pill, not one opaque chip. The operator segment is its own dropdown of that column's operators — pick a new one and it commits immediately, keeping the existing value when the new operator has the same shape (><) and resetting it when it doesn't (=is between). The value segment independently reopens the same value editor the create-new chain uses, prefilled with the current value, so adjusting a threshold is one click on just that segment rather than deleting and rebuilding the whole condition. An inline "x" on each pill removes it.
  • dataGridFilterFn — the exported filterFn. It unwraps { operator, value } and dispatches on column.meta.type, and sets autoRemove so clearing a chip removes the filter through TanStack's own columnFilters path rather than a parallel one.
  • A column already filtered drops out of the column-pick list — its chip is the edit path, so the "+ Filter" trigger only ever offers columns that don't already have a condition.
  • Narrow-container toolbarDataFilter measures its own container with a ResizeObserver (never the viewport; this is independent of DataGrid's own container measurement). Below 448px the chip chain and the "+ Filter" popover give way to two swipeable bottom-drawer toolbar buttons, so chrome height stays constant no matter how many filters are active:
    • "Sort" is labelled with the current sort column and direction. Tapping it opens a bottom drawer listing every sortable column; tapping one cycles asc → desc → unsorted and closes the drawer. Sorting on this branch is single-column — picking a different column replaces the whole sort array. A pre-existing multi-column sort (set while wide) shows as the primary column plus a muted "+N more" and is left untouched until a new pick replaces it.
    • "Filter" carries a count badge and opens a bottom drawer listing active filters as full-width rows with a persistent "Add filter" row. Tapping "Add filter" or an existing row pushes the same column → operator → value chain as a second step inside the drawer (skipping the column step when editing an existing row) — the same { operator, value } write through column.setFilterValue() as the wide-branch popover.
    • Active filters stay visible as chips below the toolbar row, not just the "Filter" badge count — mirroring the wide branch's always-visible pills instead of hiding them inside the drawer. Each chip is a ButtonGroup of two Buttons: formatFilterSummary's "column operator value" text (size="xs") plus an icon-only "×" (size="icon-xs"). Tapping the label button opens the drawer straight to that filter's operator step (skipping the list), and the "×" removes it directly without opening the drawer at all. Unlike the wide-branch pill, a narrow chip has no separate operator/value tap targets — one tap always opens the full edit chain, since three independent hit targets don't fit at this width.

Composable parts

DataFilter bundles sort + filter into one bar. When a host toolbar wants to place the pieces itself (as DataGrid does), two of them are exported separately, both taking the same table:

  • <DataFilterPopover table trigger /> — the column → operator → value chain behind a trigger element you supply.
  • <DataFilterChips table className? /> — the active-filter chip row with a trailing "Clear filters". Renders nothing when no filter is set, so it can be dropped in unconditionally.
  • <DataFilterChain table open? editColumnId? /> — the narrow-branch filter list and chain with no drawer around it, for a host sheet that wants it as one section among others. Flipping open to true resets it to the list step; editColumnId seeds it straight to that filter's operator step.

API Reference

PropTypeDefaultDescription
tableTable<TData>-A useReactTable() instance you create and own — the same one you'd pass to DataGrid. Filterable columns need meta.type and filterFn: dataGridFilterFn; the table needs getFilteredRowModel(). (required)