Skip to main content

Filtering

Column filters provide type-aware text, number, date, boolean, and set-filter interfaces. Quick filter searches across visible columns.

Loading interactive example…
React
const columns: ReactColDef[] = [
{ key: "customer", label: "Customer", filter: "text" },
{ key: "status", label: "Status", filter: "set" },
{ key: "revenue", label: "Revenue", filter: "number" },
{ key: "orderDate", label: "Ordered", type: ColumnType.DATE, filter: "tree" }, // year › month › day
];

<Grid
rowData={rows}
columnDefs={columns}
quickFilter={{
mode: "always",
behavior: "filter",
matchMode: "multiTerm",
debounceMs: 0,
showOptions: true,
showBehaviorToggle: true,
showLayoutOptions: true,
}}
onFilterChanged={(ev) => recomputeSummary(ev.source, ev.changedColIds)}
onQuickFilterFindChanged={(ev) => setMatches(ev.activeIndex, ev.matchCount)}
/>

Column filters​

Set filter: true to infer the UI from ColumnType, or choose "text", "number", "date", "boolean", "dropdown", "set", or "tree". A named kind wins over the column type. filterParams controls operators, debounce, buttons, case handling, and the number of AND/OR conditions.

const columnDefs: ColDef[] = [
{ key: "name", label: "Name", filter: "text" },
{ key: "amount", label: "Amount", type: ColumnType.NUMBER, filter: "number" },
{ key: "due", label: "Due", type: ColumnType.DATE, filter: "date" },
{ key: "active", label: "Active", type: ColumnType.BOOLEAN, filter: "boolean" },
{ key: "region", label: "Region", filter: "set" },
];

Active filters show an indicator in the header and are managed from the column's filter panel.

const column = {
key: "name",
label: "Name",
filter: "text",
filterParams: {
buttons: ["apply", "clear", "cancel"],
closeOnApply: true,
debounceMs: 200,
maxNumConditions: 2,
caseSensitive: false,
trimValues: true,
},
} satisfies ColDef;

When more than one condition is enabled, the filter UI lets the user join them with AND or OR: AND keeps a row that matches every condition, OR one that matches any. The join (FilterItem.join, "and" when absent) applies between one column's conditions; filters on different columns are always ANDed. trimValues trims only filter operands, not cell values; closeOnApply needs an Apply button to have any effect.

Restrict which operators a column offers:

const column = {
key: "amount",
label: "Amount",
filter: "number",
filterParams: {
filterOptions: [
{ value: FilterType.GTE, label: "At least" },
{ value: FilterType.LTE, label: "At most" },
{ value: FilterType.IN_RANGE, label: "Between" },
],
},
} satisfies ColDef;

Without filterOptions, each kind offers these operators, in this order:

KindOperators
text (and filter: true on a string column)Contains, Excludes, Equal, Not equal, Starts with, Ends with, Is blank, Is not blank
number, dateEqual, Not equal, Greater than, Greater than or equal, Less than, Less than or equal, Between, Not between, Is blank, Is not blank
boolean, dropdownEqual, Not equal, Is blank, Is not blank
set, treethe value checklist

Blank means null, undefined, or "". Between and Not between take two inputs, and Between includes both ends.

Dates compare by calendar day in the browser's time zone. The operands are the date input's yyyy-mm-dd text, or a Date, epoch number, or ISO text through the API; cells may hold any of the same. Equal means "on that day", Greater than "after it", Greater than or equal "on or after it", Less than "before it", Less than or equal "on or before it", and Between runs from the start of the first day through the end of the second. A cell that is not a date fails every comparison except Not equal and Not between. This applies to ColumnType.DATE columns and to any column with filter: "date"; on the server-side row model the request carries the operands exactly as entered.

Booleans get an Any / True / False choice. true, 1, and the text true, yes, y, or 1 (any case) count as true; false, 0, false, no, n, and 0 as false; anything else is neither, so it fails Equal and passes Not equal.

Dropdown (filter: "dropdown") picks one value from a static filterParams.filterValues list (["Open", "Closed"], or { value } entries) and matches it with Equal or Not equal. Without a static list it falls back to a text box; use filter: "set" for values read from the rows.

Set-filter values​

Set-filter values can come from loaded rows, a static list, or an asynchronous loader:

const staticSet = {
key: "status",
label: "Status",
filter: "set",
filterParams: {
filterValues: ["Open", "Pending", "Closed"],
},
} satisfies ColDef;

const asyncSet = {
key: "owner",
label: "Owner",
filter: "set",
filterParams: {
filterValues: async ({ success }) => {
success(await (await fetch("/api/owners")).json());
},
},
} satisfies ColDef;

A static list holds the values themselves — strings, numbers, Dates, or the objects a keyed column holds — in the order they should appear, which a static or async list keeps in both layouts. An entry written as { value: "Open" } (the older form) is unwrapped, so existing lists keep working; a column whose cell objects have their own value field lists them wrapped that way.

Set filters can keep complex raw values while defining their identity and display separately:

const regionSet = {
key: "region",
label: "Region",
filter: "set",
filterParams: {
keyCreator: value => value.code,
valueFormatter: ({ value }) => value.name,
},
} satisfies ColDef;

keyCreator is used for option deduplication, selection, API lookup, and client-side comparison; without one, values are identified the same way on both sides — text folded unless caseSensitive, a Date by its instant, an object by its content. valueFormatter controls labels, mini-filter matching, accessible names, and value ordering. comparator replaces that ordering:

const rank = { Low: 0, Medium: 1, High: 2 };
const prioritySet = {
key: "priority",
label: "Priority",
filter: "set",
filterParams: { comparator: (a, b) => rank[a.label] - rank[b.label] },
} satisfies ColDef;

It sees each side's raw value and the label the menu shows, applies to the universe read from the rows (a static or async filterValues list keeps the order it was given, in either layout), and is never handed a blank: (Blanks) stays pinned above the values. In the tree layout it orders the siblings at every level and also receives segment, level, and path; a group has no value. Filter models and Set Filter API methods continue to use the raw values. Set filterParams.showValueCounts: true to show the number of loaded leaf rows for each value. Counts cover the complete client-side row model; with the server-side row model they are necessarily limited to rows currently loaded in the grid.

Set-filter label content is independently customizable while the grid retains ownership of the checkbox and interaction behavior:

const regionColumn: ReactColDef = {
key: "region",
label: "Region",
filter: "set",
filterParams: {
showValueCounts: true,
valueComponent: RegionValue,
valueComponentParams: { showFlag: true },
selectAllComponent: SelectAllRegions,
blanksComponent: UnassignedRegion,
},
};

Omitting a component uses the built-in text. Once configured, a component that renders no content leaves that label slot intentionally empty. Mini-filtering and accessible checkbox names continue to use the underlying text labels, and a built-in count is the checkbox's accessible description ("3 rows"). A regular value component receives the loaded-row count as count; the Blanks component receives it too when that option exists.

Blanks are the grid's row, and you can widen it. null, undefined, and "" are blanks — never 0 or false — decided from the raw value and folded into (Blanks), so keyCreator and the filter's valueFormatter are never called with one. Write them for real values: value => value.code needs no guard.

To count something else as blank, return "" from keyCreator. That value joins (Blanks), skips the label callback too, and is stored as null in the filter model — which is what makes a valueGetter-derived value expressible, since only the application knows which of its shapes are empty.

The column's valueFormatter still sees blanks when rendering cells, which is how a blank cell shows an em dash. On the server-side row model the bucket travels as null, so the server applies its own blank rule and cannot see keyCreator.

Tree layout​

filter: "tree" is the set filter with its values laid out as a collapsible tree. A date column needs nothing else: its values group by year and month, with the day as the leaf. Any other column groups along the path treePathGetter returns for each value, root first and the leaf last:

const openedColumn = {
key: "opened",
label: "Opened",
type: ColumnType.DATE,
filter: "tree",
filterParams: { treeDefaultExpanded: 1 }, // years open, months closed
} satisfies ColDef;

const productColumn = {
key: "product",
label: "Product",
filter: "tree",
filterParams: {
// "Hardware/Laptops/Pro 14" → Hardware › Laptops › Pro 14
treePathGetter: value => value.split("/"),
},
} satisfies ColDef;

Every segment but the last becomes a group; values that share a prefix share those groups, and a value whose path is null or empty sits at the root with its ordinary label. treePathFormatter(segment, level, parentPath) names each segment — the built-in date path names its months in the column's formatter locale, and an application path shows String(segment) unless it formats. Siblings sort by their segment when both are numbers or dates, and by label otherwise, when the values come from the rows; a static or async filterValues list keeps the order it was given, as in the flat layout, so siblings appear in the order their values are listed.

Quarters and weeks are a path getter away. The getter receives the raw cell value — a Date on a column holding dates; parse ISO text first on a text column — and the leaf can be the date itself, formatted by level:

const openedColumn = {
key: "opened",
label: "Opened",
type: ColumnType.DATE,
filter: "tree",
filterParams: {
// 2024 › Q1 › 15 Jan: the year, the quarter, then the date itself as the leaf
treePathGetter: (d: Date) => [d.getFullYear(), Math.floor(d.getMonth() / 3) + 1, d],
treePathFormatter: (segment, level) =>
level === 1 ? `Q${segment}`
: level === 2 ? segment.toLocaleDateString(undefined, { month: "short", day: "numeric" })
: String(segment),
treeDefaultExpanded: 1, // years open, quarters closed
},
} satisfies ColDef;

Weeks need the ISO week-year rather than the calendar year, since the first days of January can belong to the last week of the year before:

/** ISO 8601 week: the year the week belongs to and its number, weeks starting Monday. */
function isoWeek(d: Date): [year: number, week: number] {
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
t.setUTCDate(t.getUTCDate() + 4 - (t.getUTCDay() || 7)); // the week's Thursday names the year
const week = Math.ceil(((t.getTime() - Date.UTC(t.getUTCFullYear(), 0, 1)) / 86400000 + 1) / 7);
return [t.getUTCFullYear(), week];
}

const filterParams: FilterParams = {
// 2024 › Week 3 › 15 Jan
treePathGetter: (d: Date) => [...isoWeek(d), d],
treePathFormatter: (segment, level) =>
level === 1 ? `Week ${segment}`
: level === 2 ? segment.toLocaleDateString(undefined, { month: "short", day: "numeric" })
: String(segment),
};

Quarters and weeks are numbers and the leaves are dates, so every level sorts in calendar order; the leaf's day and month follow the browser's locale.

Checking a group checks every leaf beneath it, a partly checked group shows as mixed, and showValueCounts sums the leaves per group. The mini filter keeps a row when its own label or an ancestor's matches, so typing a year keeps that year's months and days, and it holds every matching group open for as long as the text is there. A leaf also matches on its formatted value, the text the flat layout would have shown: a date column prints 2026-01-12 by default (formatterOptions.format changes that, or filterParams.valueFormatter for the filter alone), so typing the date narrows the tree to the day although the row only reads "12". Because that text is not on screen, the box's placeholder shows an example taken from the first value, "Type to filter values, e.g. 2026-01-12"; miniFilterPlaceholder replaces the placeholder in either layout. treeDefaultExpanded mirrors groupDefaultExpanded: 0

(the default) opens nothing, N opens the first N levels, -1 opens all. The groups a user opens or closes are remembered for as long as the grid lives, so the filter reopens as it was left (a group not seen before starts at the default depth); treeRememberExpansion: false reopens at the default depth every time.

groupComponent replaces a group row's text the way valueComponent does for a value, while the grid keeps the chevron and the checkbox. It receives the label, the summed count, the level, the path, the group's own segment, and expanded, and is refreshed when the group opens or closes:

const productColumn: ReactColDef = {
key: "product",
label: "Product",
filter: "tree",
filterParams: {
treePathGetter: value => value.split("/"),
showValueCounts: true,
groupComponent: ({ label, count }) => <span>{label} <small>{count} items</small></span>,
},
};

Groups are a presentation of the same universe, never values: the filter model holds leaf values exactly as in the flat layout, so saved filter state, the Set Filter API below, and a server-side data source are unchanged. Use keyCreator to merge values that should be one leaf — a date column holding times, keyed by its calendar day.

The Select All row is the tree's root: its chevron shows whether every group is open, none is, or only some are (a dash, like the indeterminate checkbox beside it), and opens every group when any is closed or closes them all once all are open.

Keyboard: Up/Down walk the visible rows, Right opens a group or steps into it, Left closes it or moves to its parent (Select All, for a row at the top of the tree), Home/End jump to the first and last row, and Space toggles the row's checkbox. On Select All, Right and Left open and close every group.

Assistive technology sees a tree of treeitems. Select All is the root and every other row sits beneath it, each announced with its level, its position among the siblings in view, whether it is open, and its checked state (mixed for a partly checked group); a value count reads as "3 rows". The row is the checkable item, so the checkbox inside it is not exposed on its own.

Custom matcher​

A function supplied as filter becomes the row matcher while the standard filter panel still owns its model and inputs:

const column = {
key: "tags",
label: "Tags",
filter: (value, _node, filterValues) => {
const query = String(filterValues[0] ?? "").toLowerCase();
return value.some((tag: string) => tag.toLowerCase().includes(query));
},
} satisfies ColDef;

For reusable normalization and matching, use filterParams.textFormatter and filterParams.filterFunction:

const column = {
key: "code",
label: "Code",
filter: "text",
filterParams: {
caseSensitive: false,
trimValues: true,
textFormatter: value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
filterFunction: (type, values, cell, caseSensitive, trimValues) => {
const query = trimValues ? values[0].trim() : values[0];
const [haystack, needle] = caseSensitive
? [cell, query]
: [cell.toLowerCase(), query.toLowerCase()];
return type === FilterType.STARTS_WITH && haystack.startsWith(needle);
},
},
} satisfies ColDef;

The formatter processes both cell and filter values before comparison. A filterFunction takes precedence over a function assigned to filter, while filter: false disables filtering completely. These callbacks execute only in the client-side row model; server-side filtering must implement the equivalent normalization on the server.

Programmatic filters​

getFilterModel() / setFilterModel(filters) / addFilterModel(filter) / removeFilterModel(colId) read and write all column filters as serializable { colId, filters, join } items addressed by the public ColDef.colId:

api.setFilterModel([
{ colId: "amount", filters: [{ type: FilterType.GTE, values: [100] }] },
]);

Set filter by intent​

For set-filter columns, prefer the intent-level helpers over manipulating in/notIn defs directly — the set filter manages its own storage:

await api.getSetFilterValues("region"); // the value universe (null = blanks)
await api.uncheckSetFilterValue("region", "EMEA"); // hide EMEA rows
await api.checkSetFilterValue("region", "EMEA"); // show them again
await api.getSetFilterState("region"); // { mode, checked, unchecked } or null
await api.setSetFilterValues("region", ["EMEA"], { mode: "include" });

Inputs are resolved against the column's value universe, so a string "5" finds the numeric 5 your rows hold, and null addresses the "(Blanks)" bucket. The methods are async because filter values may come from an async source; with the default from-rows universe they resolve immediately.

The one semantic worth choosing deliberately is mode — what happens to values that arrive after filtering (new rows, edited cells):

  • "exclude" (the menu's default behavior): the unchecked values are stored; anything else, including later arrivals, stays visible.
  • "include": the checked values are stored; anything else, including later arrivals, is hidden — "show only what I checked".

An explicit mode passed to setSetFilterValues is pinned on the filter: subsequent menu or helper toggles keep that representation instead of optimizing storage to the shorter list.

A tree-layout column adds helpers that address its groups by path:

await api.getSetFilterTree("opened"); // [{ path: [2024], label: "2024", checked: "mixed", children: [...] }, ...]
await api.uncheckSetFilterPath("opened", [2024, 1]); // hide January 2024 — every day under it
await api.checkSetFilterPath("opened", [2024]); // show all of 2024 again

A path names a group, or one leaf by its full path. Segments compare by type and value as the tree built them, so the built-in date path takes numbers. Each node carries its label, its count when showValueCounts is on, and checked ("mixed" for a partly checked group); leaves carry their value, the blanks bucket as a root leaf with value: null. The filter model stays leaf-only, so these are the same in/notIn defs as the value helpers produce. Off a tree column they warn and return null or do nothing.

Quick filter​

const options = {
quickFilter: {
mode: "always",
matchMode: "multiTerm",
caseSensitive: false,
debounceMs: 100,
showOptions: true,
},
} satisfies GridOptions;

api.setQuickFilter("open europe");
api.setQuickFilter("exact phrase", { matchMode: "substring" });
api.setQuickFilter("42", { matchMode: "wholeCell" });

Three match modes, all compared against each cell's formatted display value (so the user searches what they see, $1,200.00 included):

matchModeMatches whenNotes
multiTerm (default)every whitespace-separated word is foundwhile filtering, the words may sit in different cells of the row; while finding, all of them must be in the one cell being highlighted
substringthe whole input is one contiguous run inside a cella run never spans two columns
wholeCella cell's entire text equals the inputno partial hits: 42 matches neither 142 nor 4.2. Both sides are trimmed

wholeCell is the exact lookup a global search otherwise lacks — "the row whose id is exactly 42", without knowing which column holds it. Its one sharp edge is formatting: a column rendered $1,200.00 has to be typed that way, since equality has none of substring's forgiveness. Note also that it cannot express "cells that are empty" — an empty search box means no search — which stays the job of a column filter's isBlank. It is not the same comparison as a column eq filter either: that normalizes the raw value through textFormatter / trimValues, while the quick filter compares what is rendered.

On the client-side row model the grid applies the quick filter itself. On the server-side row model the server does: each getRows (and getAggregates) request carries the search as request.quickFilter — { text, mode, caseSensitive }, absent while the box is empty — next to the structured column filters, and a change of text, mode or case refetches from the first block, as a column-filter change does. See the request contract.

The floating widget​

The search box lives inside the grid, in the strip just below the header and against its right edge by default. mode decides whether it is there all the time:

quickFilter: {
mode: "onDemand", // Ctrl/Cmd+F opens it | "always" pins it open
position: { anchor: "right", offsetX: 8, offsetTop: 6 },
clearOnClose: false, // keep filtering after dismissal
showOptions: true, // match-mode + match-case popover
showLayoutOptions: true, // ...plus Anchor and "Keep filter when closed"
}

position moves it: anchor picks the edge, offsetX insets it from that edge (on top of the scrollbar gutter, so it never sits over the thumb) and offsetTop is the gap below the header. With clearOnClose: false a dismissed search keeps filtering, and a compact pill takes the widget's place so the filtering is never silent — clicking it reopens the box. Every option is listed in Quick filter options.

The alternative is to hand the widget to the toolbar, which puts it outside the data region entirely; position, clearOnClose and showLayoutOptions then do not apply, because the bar owns the layout.

Find instead of filter​

behavior: "find" turns the same box into Excel's Find: nothing is filtered — every row stays where it is and each cell whose own text contains the search string is highlighted. The widget counts the matches inside the search box (3/27 — the active match over the total, 0/0 when nothing matches), with next/previous steppers beside it.

const options = {
quickFilter: {
mode: "always",
behavior: "find", // "filter" (default) | "find"
showBehaviorToggle: true, // let the end user switch; omit to force `behavior`
caseSensitive: false,
},
} satisfies GridOptions;

api.setQuickFilter("smith", { behavior: "find" });
api.findNext(); // → { rowId, colId, colInstanceId } | null
api.findPrevious();
api.getFindState(); // { behavior, available, text, matchCount, activeIndex, activeMatch }

The two behaviors share the search text, matchMode and caseSensitive, so switching between them re-runs the same search the other way. Editing those settings while finding is a search edit, not a filter edit: no rows move, the page holds, and the selection is left alone. Set behavior alone to force one of them; add showBehaviorToggle to offer the choice in the widget's options popover, where the end user's pick is sticky for the session (like match case, it is not written back to grid options).

A match is always scoped to one cell — a highlight has to land on a single cell — and matchMode picks the shape within that scope, exactly as it does when filtering. The one value that reads differently is multiTerm: filtering lets its words land in different cells of a row, finding requires them all in the cell it highlights, which makes john smith find Smith, John. wholeCell and substring mean the same thing in both behaviors.

What is searched, and what is not:

  • Every visible data-row cell, across all pages and inside collapsed groups — matchCount does not depend on what is scrolled into view, and findNext() reveals its match by expanding ancestors and paging to it.
  • Not group rows: their cells hold a hierarchy label or an aggregate the grid derives, not a column value. The auto-group column, tree column, row-number and checkbox columns are skipped for the same reason. A hidden column is not searched either.
  • matchCount counts cells: a term in three columns of one row is three matches, each separately reachable.

Finding needs data-row cells to highlight, so it is unavailable on the server-side row model and while the pivot layout is displayed (every displayed row there is a group row). In both cases getFindState().available is false and the search falls back to filtering rather than going inert — the widget's toggle disables itself and says why. On the server-side row model that means the text is sent to the server as request.quickFilter and narrows the rows; the grid deliberately does not "find" over the blocks it happens to have loaded, since a count and next/previous over a partial dataset would be wrong. Asking for behavior: "find" there logs one console.warn.

Keyboard: Enter steps to the next match, Shift+Enter to the previous, both wrapping at the ends. Focus stays in the search box, so the cell cursor and the selection are left alone — stepping through matches is a search gesture, not a navigation one.

Subscribe to quickFilterFindChanged (or the onQuickFilterFindChanged option / the wrappers' quickFilterFindChanged output) to drive an app-owned counter; a finding quick filter deliberately does not fire filterChanged, since no rows moved.

Theming: --pte-find-match-bg-color tints every match, --pte-find-match-active-bg-color plus --pte-find-match-active-border-color mark the active one — or set all three from one color with the findMatchColor theme parameter.

Keeping matches visible​

While filtering, the widget is chrome you dismiss once the search is typed. While finding it is the opposite: a control surface you keep operating — next, previous, the counter — as you read the cells it points at. So a match that lands underneath it would be unreachable, since dismissing the box to look means losing the steppers.

Stepping to a match therefore moves it out from under the widget. The grid scrolls the match clear where it can, and where no scroll can, the widget itself gives way and flips to the opposite edge. Those unscrollable cases are exactly where the default placement sits:

  • the first row, already at the top of its scroll range — the content cannot move down;
  • the last column, already at the end of the horizontal range;
  • a pinned column, which does not scroll at all;
  • a row docked in a frozen band, which stays put wherever the body is scrolled.

The flip lasts as long as the search does. It never rewrites position.anchor or the end user's Anchor pick: closing the search, emptying the box, or choosing an anchor brings the widget home. It is also held back while the pointer is over the widget — so clicking next or previous repeatedly cannot move the button out from under the cursor — and taken as soon as the pointer leaves. api.findNext() and api.findPrevious() reveal their match the same way, since they are the same navigation the steppers perform.

A widget hosted in the toolbar sits outside the data region and never needs any of this.

Reacting to filter changes​

Subscribe to the canonical filterChanged event (or pass the onFilterChanged callback / bind the wrapper's filterChanged output) to recompute anything filter-derived — footer aggregates, counts, external summaries. It fires once per effective filter change, whatever the path:

  • source: "filter" — a column-filter model change (menu, API, dispatch), with the affected public changedColIds;
  • source: "quickFilter" — a quick-filter change (changedColIds is empty). On the server-side model it fires when the refetch is requested, not when the rows arrive. A finding quick filter (behavior: "find") changes no rows and so fires quickFilterFindChanged instead;
  • source: "columns" — a columnDefs update dropped an active filter.

On the client-side row model the handler runs after the view is re-derived, so row counts and api.getPaginationInfo() already reflect the new filter:

api.on("filterChanged", () => {
const { totalRowCount, pageIndex, totalPageCount } = api.getPaginationInfo();
status.textContent = `${totalRowCount} rows — page ${pageIndex + 1} of ${totalPageCount}`;
});

By default a filter change keeps the user's current page, clamping to the last page when the result shrinks past it; list "filter" / "quickFilter" in resetPageOn to jump back to page 1 instead.