# Filtering

Operator-based column filters: the built-in header menu, per-column config, and the controlled filters API.


Every column is filterable **by default**. Hover a header row and a funnel button appears in each header; click it and a small menu opens with an operator select and a typed value control. On a touch device there is no hover, so the funnel is [drawn all the time](#the-funnel-on-touch) instead. Filters apply live as you type, combine across columns with AND, and survive row updates — set `filterable: false` on a column to opt it out.

Filters are the `filters` field of one typed query object alongside `sort` and `rowGroups`. The grid owns that query by default. To control it, pass the exact `query` and `onQueryChange` pair; partial ownership is intentionally rejected.

Hover a header for its funnel (on a phone it is already drawn), open the **Status** menu to watch its checklist load distinct values from the rows rather than a declared list, and set only one of **Total**'s `between` bounds to see the column stay unfiltered until both are set:

### Example: Column filters

One filterable column per type — text, number, enum, date, and boolean — reaching each operator family through the built-in funnel menu, with the enum column declaring no options so its checklist loads distinct values from the rows instead.

Source: https://pretable.ai/examples/column-filters.md

```tsx ColumnFiltersGrid.tsx
"use client";

import { useState, type ComponentProps } from "react";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { type Order, orders } from "./data";

const VIEWPORT_HEIGHT = 320;

export function ColumnFiltersGrid() {
  // The query is controlled and seeded with an initial filter so the active
  // filters can be echoed below the grid. Omit both query props to let rows
  // mode own filtering uncontrolled instead — nothing here requires control.
  //
  // Typed via `ComponentProps`, not `PretableQueryFor<typeof columns>`: these
  // plain PretableColumn<Order>[] carry no `accessor` field, which
  // `PretableQueryFor` requires — applied directly it collapses every filter
  // to `never`. Pulling the type through the prop instead picks up
  // `<PretableSurface>`'s own fallback for accessor-less columns.
  const [query, setQuery] = useState<
    NonNullable<ComponentProps<typeof PretableSurface<Order>>["query"]>
  >({
    filters: [{ columnId: "status", operator: "isAnyOf", value: ["open"] }],
    sort: [],
    rowGroups: [],
  });

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Hover a header for its funnel — on a touch device it is drawn all the
        time, since there is no hover to reveal it with. <strong>Status</strong>{" "}
        declares no <code>options</code>, so its checklist loads distinct values
        from the rows. <strong>Total</strong>&rsquo;s <code>between</code> waits
        for both bounds before it filters anything.
      </p>
      <PretableSurface<Order>
        ariaLabel="Orders"
        columns={columns}
        getRowId={(row) => row.id}
        onQueryChange={setQuery}
        query={query}
        rows={orders}
        viewportHeight={VIEWPORT_HEIGHT}
      />
      <p style={{ margin: "8px 0 0", fontSize: 13 }}>
        Active filters:{" "}
        <code>
          {query.filters.length > 0
            ? query.filters
                .map((filter) => `${filter.columnId} ${filter.operator}`)
                .join(" · ")
            : "(none)"}
        </code>
      </p>
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { Order } from "./data";

export const columns: PretableColumn<Order>[] = [
  { id: "customer", header: "Customer", widthPx: 110 },
  { id: "total", header: "Total", type: "number", widthPx: 75 },
  // No `options` — the checklist loads its distinct values from the rows.
  { id: "status", header: "Status", type: "enum", widthPx: 95 },
  { id: "placedAt", header: "Placed", type: "date", widthPx: 100 },
  { id: "expedited", header: "Expedited", type: "boolean", widthPx: 115 },
];
```

```ts data.ts
export interface Order {
  id: string;
  customer: string;
  total: number;
  status: "open" | "shipped" | "cancelled";
  placedAt: string;
  expedited: boolean;
}

export const orders: Order[] = [
  {
    id: "o1",
    customer: "Ada Lovelace",
    total: 128,
    status: "open",
    placedAt: "2026-08-01",
    expedited: true,
  },
  {
    id: "o2",
    customer: "Grace Hopper",
    total: 412,
    status: "shipped",
    placedAt: "2026-08-03",
    expedited: false,
  },
  {
    id: "o3",
    customer: "Linus Torvalds",
    total: 76,
    status: "cancelled",
    placedAt: "2026-08-04",
    expedited: true,
  },
  {
    id: "o4",
    customer: "Margaret Hamilton",
    total: 205,
    status: "open",
    placedAt: "2026-08-06",
    expedited: false,
  },
  {
    id: "o5",
    customer: "Alan Turing",
    total: 340,
    status: "shipped",
    placedAt: "2026-08-07",
    expedited: true,
  },
  {
    id: "o6",
    customer: "Katherine Johnson",
    total: 58,
    status: "open",
    placedAt: "2026-08-08",
    expedited: true,
  },
  {
    id: "o7",
    customer: "Dennis Ritchie",
    total: 289,
    status: "shipped",
    placedAt: "2026-08-10",
    expedited: false,
  },
];
```


## Column config

Three column fields shape filtering:

| Field        | Type                                                  | Notes                                                                     |
| ------------ | ----------------------------------------------------- | ------------------------------------------------------------------------- |
| `type`       | `"text" \| "number" \| "date" \| "enum" \| "boolean"` | picks the operator family and value control; defaults to `"text"`         |
| `options`    | `{ value: string; label?: string }[]`                 | the checklist for an `enum` column; omit to auto-derive from the rows     |
| `filterable` | `boolean`                                             | `false` removes the funnel and makes the engine ignore filters for the id |

```tsx
import type { PretableColumn } from "@pretable/react";

const columns: PretableColumn<Order>[] = [
  { id: "customer", header: "Customer" }, // text — the default
  { id: "total", header: "Total", type: "number" },
  {
    id: "status",
    header: "Status",
    type: "enum",
    options: [
      { value: "open", label: "Open" },
      { value: "shipped", label: "Shipped" },
    ],
  },
  { id: "placedAt", header: "Placed", type: "date" },
  { id: "actions", header: "", filterable: false },
];
```

When an `enum` column omits `options`, the menu requests a bounded distinct-value page from the row model. Loading, errors, and cancellation are handled asynchronously; null and blank values are excluded by default.

## Operators

Each `type` gets its own operator family, plus two shared operators available everywhere:

| Family    | Operators                                                                                                     |
| --------- | ------------------------------------------------------------------------------------------------------------- |
| `text`    | `contains`, `notContains`, `equals`, `notEquals`, `startsWith`, `endsWith`                                    |
| `number`  | `equals`, `notEquals`, `gt` (greater than), `gte`, `lt` (less than), `lte`, `between`                         |
| `date`    | `on`, `before`, `after`, `dateBetween`                                                                        |
| `enum`    | `isAnyOf`, `isNoneOf`                                                                                         |
| `boolean` | enum operators (`isAnyOf`, `isNoneOf`, plus the shared empty checks) over implicit `True`/`False` options     |
| shared    | `isEmpty`, `isNotEmpty` — no value; a cell is "empty" when it's `null`, `undefined`, `NaN`, or a blank string |

Semantics worth knowing:

- **Evaluation keys on `type`, not the operator name.** `equals` on a `text` column is case-insensitive string equality; on a `number` column it's numeric equality. An operator outside the column's family simply matches nothing.
- **Columns AND-combine.** A row must pass every column's active filter. Within an `enum` column, `isAnyOf` is an OR across the selected values.
- **Text operators are case-insensitive.** Both cell and value are lowercased before comparison.
- **Boolean cells are coerced before matching.** `"true"` / `1` / `"1"` match **True**, `"false"` / `0` / `"0"` match **False**, and anything else falls back to plain truthiness — the same rule the [editable checkbox](/docs/grid/editing#booleans) renders from, so a cell that looks checked also matches the True filter. (`isEmpty` / `isNotEmpty` run before coercion, so `null` still reads as empty rather than as `false`.) A boolean column's `options` may relabel the two states (`{ value: "true", label: "Yes" }`), but their **values** must remain `"true"` and `"false"` — those are the only strings a coerced cell can equal, so any other value matches nothing.
- **`between` / `dateBetween` are inclusive** and order-tolerant — swapped bounds are normalized.
- **Dates compare at day resolution** (calendar day, UTC), so `on` matches any timestamp within that day. Cells may be `YYYY-MM-DD` strings, ISO datetimes, `Date` instances, or epoch milliseconds; a **zone-less** datetime (`"2026-08-06T13:45:00"`, or the space-separated `"2026-08-06 13:45:00"` that SQL backends emit) is read as UTC — its literal date portion — while a zoned one buckets by the UTC day of that instant. Ambiguous shapes (`"08/06/2026"`, `"2026-8-6"`) and calendar overflow (`"2026-02-30"`) are not dates and match no date operator. The [date editor](/docs/grid/editing#dates) reads cell values by exactly the same rule, so editing a cell can't move a row out from under its own filter.
- **Blank operands are inactive.** A filter with an empty string, empty array, or `null` value is ignored — it's as if the column had no filter.

## The built-in menu

The funnel button renders in every filterable header. With the `@pretable/ui` skin **on a fine pointer** it stays hidden until you hover the header row, focus it with the keyboard, or the column has an active filter — an active funnel stays visible and tinted with the accent color. On a coarse pointer it is always drawn; see [The funnel on touch](#the-funnel-on-touch).

Clicking the funnel opens a popover (`role="dialog"`, labeled `Filter {header}`) anchored under the button, with the operator `<select>` focused. From there:

- **Filters apply live.** Free-text and number typing is debounced (~200&nbsp;ms); operator changes, date picks, and enum checkboxes apply immediately. Closing the menu flushes any pending keystrokes.
- **Ranges wait for both bounds.** `between` / `dateBetween` only take effect once min and max are both set (and numeric, for numbers). An incomplete value clears the column's filter rather than half-applying it.
- **Enum checklists are permissive when empty.** Zero boxes checked means no constraint, not zero rows.
- **Clear** resets the column's filter and the menu's controls in one click.
- **`Escape`, clicking outside, or scrolling** closes the menu. Filters keep applying after it closes — the funnel's tint tells you which columns are constrained.

For styling or testing, the parts expose stable DOM hooks: the button carries `data-pretable-filter-funnel`, `data-pretable-column-id`, and `data-pretable-filter-active`; the menu carries `data-pretable-filter-menu`, with `data-pretable-filter-operator`, `data-pretable-filter-value`, `data-pretable-filter-min` / `-max`, `data-pretable-filter-set`, and `data-pretable-filter-clear` on its controls — the same funnel and menu you opened in the grid above.

### The funnel on touch

The funnel is not a tab stop, and on a phone there is nothing to hover. Both routes to it are therefore explicit:

- **By finger.** Under `@media (pointer: coarse)` the `@pretable/ui` skin draws the funnel at `opacity: 1` at rest, on every filterable column, whether or not a filter is active. Hover-reveal is not subtlety on a device with no hover — it is absence, and it made a control nobody could see. The fine-pointer reveal above is unchanged. The tap target is 24×24 (a transparent `::after`; the glyph itself stays 18×18, so the header box is the same size it was), which is [WCAG 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) Level AA.
- **By keyboard.** `↑` from the first data row puts the focus cursor on that column's header, and `Alt + ↓` opens the funnel's popover from there. `Escape` closes it and returns focus to the header. See [Keyboard § The column header](/docs/grid/keyboard#the-column-header).

The column menu (`⋮`, rendered on data columns when [`groupPanel={{ enabled: true }}`](/docs/grid/grouping)) follows the same two rules: always drawn on a coarse pointer, with a 24×24 target, and opened from the keyboard with `Shift + F10`.

The resize strip is the one header control that goes the other way — it is **not rendered** on a coarse pointer at all, which is where the space for the two 24px targets above comes from. See [Column layout § Resizing is a pointer affordance](/docs/grid/column-layout#resizing-is-a-pointer-affordance).

## Controlling the query

Omit both query props for uncontrolled filtering. To seed, observe, or persist filters, own the complete typed query and publish every returned query — the example above does exactly this: it seeds an initial `status` filter and echoes the active filters below the grid. Its `ColumnFiltersGrid.tsx` source (Code tab) shows the full pattern. With plain (non-`createColumnHelper`) columns, type the query by pulling `PretableSurface`'s own `query` prop through `ComponentProps` rather than applying `PretableQueryFor` directly to `typeof columns` — those columns carry no `accessor` field, which `PretableQueryFor` needs to resolve anything but `never`.

Because you own the query, clear filters with `setQuery((current) => ({ ...current, filters: [] }))`. Funnels and menus reflect the published value. An already-open menu keeps its in-progress draft and re-reads the controlled value the next time it opens.

<Callout type="note">
  Controlled query props belong to rows mode. In explicit-model mode, call
  `rowModel.setQuery(...)`; passing `query` props to the surface is a type
  error.
</Callout>

## Headless

Headless filtering is part of the row model's complete typed query:

```ts
const transition = rowModel.setQuery({
  ...rowModel.getState().snapshot.query,
  filters: [{ columnId: "status", operator: "isAnyOf", value: ["open"] }],
});
await transition.finished;

const values = rowModel.distinctValues("status", { limit: 100 });
const page = await values.finished;
```

Query publication is atomic and distinct-value lookup is asynchronous and cancellable. Render the required result window with `snapshot.range(start, end)`. See the [headless API reference](/docs/headless/api-reference).

## See also

- [Selection](/docs/grid/selection) — selection state under a filtered row set.
- [Row grouping and aggregation](/docs/grid/grouping) — choose whether aggregates use filtered or all rows.
- [`<PretableSurface>`](/docs/grid/pretable-surface) — the `state` prop and the controlled/uncontrolled pattern.
- [API reference](/docs/grid/api-reference) — `ColumnFilter`, `FilterOperator`, `ColumnType`, `ColumnOption` types.
