ComponentsFilterBar
FilterBar
Narrow a list with filters that read as sentences. Each chip is field, operator, and value, and every part can be edited in place. The chips edit one plain-JSON filter model, so the same filter can drive a table, a URL, or a server query.
On this page
Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.
View registry files- Internationalized Date
(@internationalized/date)Date values, calendar arithmetic, and time-zone-aware date handling.
- React Aria Components
(react-aria-components)Accessible interaction primitives, including keyboard behavior, focus handling, and ARIA semantics.
- React Aria
(react-aria)· via shared Dethink componentsAccessibility hooks and overlay utilities used by the implementation.
Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.
Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.
Import the component into your page or component file.
import {
FilterBar,
createFilterPredicate,
defineFilterFields,
useFilterState,
} from "@dethink/components";Add a filter, then click any part of a chip to change it.
Filtering a DataTable
The bar sits in the table's toolbar and createFilterPredicate filters the rows. Option lists show how many rows each choice would match, chips show how many rows they hide, and when nothing matches the bar suggests which chip to relax. Try Created is in the last 7 days, Estimate is at least 5, or Customer reported. Press F to add a filter, arrow keys to move between chip parts, Backspace to remove a chip, and Cmd or Ctrl+Z to undo.
Labels | |||||||
|---|---|---|---|---|---|---|---|
| DT-412 | API latency spike on /search | Blocked | urgent | bug, api | Ada | 5 | 2026-09-29 |
| DT-418 | Billing export drops tax lines | Open | high | bug, billing | Lin | 3 | 2026-09-24 |
| DT-425 | Usage dashboard for workspaces | Open | medium | feature | — | 13 | 2026-09-02 |
| DT-433 | Audit log retention settings | Open | low | feature, auth | Sam | 5 | 2026-09-15 |
| DT-437 | Webhook retries back off too fast | Blocked | high | api | — | 3 | 2026-09-28 |
Show sourceexamples/filter-bar/issue-table.tsx
"use client";
import { useMemo } from "react";
import {
Badge,
DataTable,
FilterBar,
createFilter,
createFilterCondition,
createFilterPredicate,
defineFilterFields,
useFilterState,
type BadgeTone,
type DataTableColumnDef,
} from "@dethink/components";
type Status = "open" | "blocked" | "in-review" | "done";
type Issue = {
id: string;
title: string;
status: Status;
priority: "urgent" | "high" | "medium" | "low";
labels: string[];
assignee: string | null;
estimate: number;
created: string;
customer: boolean;
};
// A fixed "today" keeps relative dates and counts identical on the server
// and in the browser. In an app, pass the request time instead.
const today = Date.UTC(2026, 8, 30, 12);
const evaluateOptions = { now: today };
const statusOptions = [
{ value: "open", label: "Open" },
{ value: "blocked", label: "Blocked" },
{ value: "in-review", label: "In review" },
{ value: "done", label: "Done" },
];
const statusTone: Record<Status, BadgeTone> = {
open: "info",
blocked: "destructive",
"in-review": "warning",
done: "success",
};
const issues: Issue[] = [
{
id: "DT-412",
title: "API latency spike on /search",
status: "blocked",
priority: "urgent",
labels: ["bug", "api"],
assignee: "Ada",
estimate: 5,
created: "2026-09-29",
customer: true,
},
{
id: "DT-418",
title: "Billing export drops tax lines",
status: "open",
priority: "high",
labels: ["bug", "billing"],
assignee: "Lin",
estimate: 3,
created: "2026-09-24",
customer: true,
},
{
id: "DT-421",
title: "SSO login loops on Safari",
status: "in-review",
priority: "high",
labels: ["bug", "auth"],
assignee: "Ada",
estimate: 8,
created: "2026-09-30",
customer: false,
},
{
id: "DT-425",
title: "Usage dashboard for workspaces",
status: "open",
priority: "medium",
labels: ["feature"],
assignee: null,
estimate: 13,
created: "2026-09-02",
customer: false,
},
{
id: "DT-430",
title: "Rate limit headers on public API",
status: "done",
priority: "medium",
labels: ["api"],
assignee: "Sam",
estimate: 2,
created: "2026-08-18",
customer: true,
},
{
id: "DT-433",
title: "Audit log retention settings",
status: "open",
priority: "low",
labels: ["feature", "auth"],
assignee: "Sam",
estimate: 5,
created: "2026-09-15",
customer: false,
},
{
id: "DT-437",
title: "Webhook retries back off too fast",
status: "blocked",
priority: "high",
labels: ["api"],
assignee: null,
estimate: 3,
created: "2026-09-28",
customer: true,
},
];
const fields = defineFilterFields<Issue>([
{
key: "status",
label: "Status",
type: "option",
options: statusOptions,
},
{
key: "priority",
label: "Priority",
type: "option",
options: [
{ value: "urgent", label: "Urgent" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
],
},
{
key: "labels",
label: "Labels",
type: "multiOption",
options: [
{ value: "bug", label: "Bug" },
{ value: "api", label: "API" },
{ value: "auth", label: "Auth" },
{ value: "billing", label: "Billing" },
{ value: "feature", label: "Feature" },
],
},
{
key: "assignee",
label: "Assignee",
type: "text",
placeholder: "Name",
},
{ key: "title", label: "Title", type: "text", placeholder: "Contains…" },
{ key: "estimate", label: "Estimate", type: "number" },
{ key: "created", label: "Created", type: "date" },
{
key: "customer",
label: "Customer reported",
type: "boolean",
},
]);
const columns: DataTableColumnDef<Issue>[] = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "title", header: "Title" },
{
accessorKey: "status",
header: "Status",
cell: ({ getValue }) => {
const status = getValue<Status>();
return (
<Badge tone={statusTone[status]} variant="soft" size="sm">
{statusOptions.find((option) => option.value === status)?.label}
</Badge>
);
},
},
{ accessorKey: "priority", header: "Priority" },
{
accessorKey: "labels",
header: "Labels",
enableSorting: false,
cell: ({ getValue }) => getValue<string[]>().join(", "),
},
{
accessorKey: "assignee",
header: "Assignee",
cell: ({ getValue }) => getValue<string | null>() ?? "—",
},
{ accessorKey: "estimate", header: "Est." },
{ accessorKey: "created", header: "Created" },
];
export function FilterBarIssueTable() {
const filterState = useFilterState({
defaultValue: createFilter({
id: "issues",
children: [
createFilterCondition({
id: "status",
field: "status",
operator: "isAnyOf",
value: ["open", "blocked"],
}),
],
}),
});
const rowFilter = useMemo(
() => createFilterPredicate(filterState.filter, fields, evaluateOptions),
[filterState.filter],
);
return (
<DataTable
aria-label="Issues"
columns={columns}
data={issues}
getRowId={(row) => row.id}
rowFilter={rowFilter}
emptyContent="No issues match these filters."
toolbar={
<FilterBar
fields={fields}
state={filterState}
data={issues}
evaluateOptions={evaluateOptions}
showImpact
addShortcut="f"
/>
}
/>
);
}AND/OR groups
Nested groups show as one chip; click it, or use Advanced, to edit. The same filter also drives an inline FilterGroupEditor: switch a group to any, wrap a row in a new group, or move rows with Alt+Arrow keys.
Status is not Done, and (Priority is Urgent, or Labels includes any of Security, Customer)
Inline group editor
- Edit group, Priority is Urgent, or Labels includes any of Security, Customer
Show sourceexamples/filter-bar/groups.tsx
"use client";
import { useState } from "react";
import {
FilterBar,
FilterGroupEditor,
createFilter,
createFilterCondition,
defineFilterFields,
describeFilter,
type Filter,
} from "@dethink/components";
const fields = defineFilterFields([
{
key: "status",
label: "Status",
type: "option",
options: [
{ value: "open", label: "Open" },
{ value: "blocked", label: "Blocked" },
{ value: "done", label: "Done" },
],
},
{
key: "priority",
label: "Priority",
type: "option",
options: [
{ value: "urgent", label: "Urgent" },
{ value: "high", label: "High" },
{ value: "low", label: "Low" },
],
},
{
key: "labels",
label: "Labels",
type: "multiOption",
options: [
{ value: "bug", label: "Bug" },
{ value: "security", label: "Security" },
{ value: "customer", label: "Customer" },
],
},
{ key: "assignee", label: "Assignee", type: "text", placeholder: "Name" },
]);
export function FilterBarGroups() {
const [filter, setFilter] = useState<Filter>(() =>
createFilter({
id: "triage",
children: [
createFilterCondition({
id: "status",
field: "status",
operator: "isNoneOf",
value: ["done"],
}),
createFilter({
id: "hot",
combinator: "or",
children: [
createFilterCondition({
id: "priority",
field: "priority",
operator: "isAnyOf",
value: ["urgent"],
}),
createFilterCondition({
id: "labels",
field: "labels",
operator: "includesAny",
value: ["security", "customer"],
}),
],
}),
],
}),
);
return (
<div className="grid gap-4">
<FilterBar fields={fields} value={filter} onValueChange={setFilter} />
<p className="text-muted-foreground text-sm">
{describeFilter(filter, fields)}
</p>
<div className="rounded-lg border p-4">
<p className="mb-3 text-sm font-medium">Inline group editor</p>
<FilterBar fields={fields} value={filter} onValueChange={setFilter}>
<FilterGroupEditor className="basis-full" />
</FilterBar>
</div>
</div>
);
}Server mode
The filter is plain JSON, so it can go to your API as is. Here a pretend server filters 240 orders, returns a page and the total, and counts options for the pickers through getFacets. Counts show a placeholder while they load, and the table keeps its rows while the next page loads, with a thin progress line, so nothing jumps.
Loading table data. | ||||
Show sourceexamples/filter-bar/server-mode.tsx
"use client";
import {
DataTable,
FilterBar,
QueryInput,
createFilter,
createFilterCondition,
defineFilterFields,
useFilterState,
type DataTableColumnDef,
} from "@dethink/components";
import { createMockFilterApi } from "../filter-server/mock-api";
import { PendingLine } from "../filter-server/pending-line";
import { useServerRows } from "../filter-server/use-server-rows";
type Order = {
id: string;
customer: string;
region: "emea" | "amer" | "apac";
plan: "starter" | "team" | "enterprise";
amount: number;
refunded: boolean;
};
const regions = ["emea", "amer", "apac"] as const;
const plans = ["starter", "team", "enterprise"] as const;
const customers = ["Acme", "Globex", "Initech", "Umbrella", "Hooli", "Stark"];
// Deterministic sample data, standing in for a database table.
const orders: Order[] = Array.from({ length: 240 }, (_, index) => ({
id: `ORD-${1000 + index}`,
customer: customers[index % customers.length] ?? "Acme",
region: regions[(index * 7) % 3] ?? "emea",
plan: plans[(index * 5) % 3] ?? "team",
amount: ((index * 137) % 4900) + 100,
refunded: index % 11 === 0,
}));
const fields = defineFilterFields<Order>([
{ key: "customer", label: "Customer", type: "text" },
{
key: "region",
label: "Region",
type: "option",
options: [
{ value: "emea", label: "EMEA" },
{ value: "amer", label: "Americas" },
{ value: "apac", label: "APAC" },
],
},
{
key: "plan",
label: "Plan",
type: "option",
options: [
{ value: "starter", label: "Starter" },
{ value: "team", label: "Team" },
{ value: "enterprise", label: "Enterprise" },
],
},
{ key: "amount", label: "Amount", type: "number" },
{ key: "refunded", label: "Refunded", type: "boolean" },
]);
// The "server": receives the filter as JSON, returns a page and counts.
const api = createMockFilterApi(orders, fields, { latency: 500 });
const columns: DataTableColumnDef<Order>[] = [
{ accessorKey: "id", header: "Order" },
{ accessorKey: "customer", header: "Customer" },
{ accessorKey: "region", header: "Region" },
{ accessorKey: "plan", header: "Plan" },
{ accessorKey: "amount", header: "Amount" },
];
export function FilterBarServerMode() {
const state = useFilterState({
defaultValue: createFilter({
id: "orders",
children: [
createFilterCondition({
id: "plan",
field: "plan",
operator: "isAnyOf",
value: ["enterprise"],
}),
],
}),
});
const result = useServerRows(state.filter, api.search);
return (
<div className="relative grid gap-2" aria-busy={result.pending}>
{/* A thin progress line instead of dimming: text keeps its contrast. */}
<PendingLine active={result.pending && !result.initial} />
<DataTable
aria-label="Orders"
columns={columns}
data={result.rows}
getRowId={(row) => row.id}
// The server filters; the table shows what it returns.
manualFiltering
loading={result.initial}
error={result.failed ? "Couldn't load orders." : undefined}
emptyContent="No orders match."
toolbar={
<div className="grid w-full gap-2">
<QueryInput fields={fields} state={state} controlSize="sm" />
<FilterBar
fields={fields}
state={state}
getFacets={api.facets}
resultCount={result.pending ? undefined : result.total}
/>
</div>
}
/>
{/* Fixed height: the status never pushes the table around. */}
<p className="text-muted-foreground h-5 text-xs" aria-hidden="true">
{result.pending
? "Updating…"
: result.total === undefined
? ""
: `${result.total} orders on the server; showing ${result.rows.length}.`}
</p>
</div>
);
}One filter model
The chips edit a plain JSON tree. describeFilter turns it into the sentence screen readers hear.
Region is EU West, and Tags includes all of GPU, Spot
{
"type": "group",
"id": "hosts",
"combinator": "and",
"children": [
{
"type": "condition",
"id": "region",
"field": "region",
"operator": "isAnyOf",
"value": [
"eu-west"
]
},
{
"type": "condition",
"id": "tags",
"field": "tags",
"operator": "includesAll",
"value": [
"gpu",
"spot"
]
}
]
}Show sourceexamples/filter-bar/one-model.tsx
"use client";
import { useState } from "react";
import {
FilterBar,
createFilter,
createFilterCondition,
defineFilterFields,
describeFilter,
type Filter,
} from "@dethink/components";
const fields = defineFilterFields([
{
key: "region",
label: "Region",
type: "option",
options: [
{ value: "us-east", label: "US East" },
{ value: "eu-west", label: "EU West" },
{ value: "ap-south", label: "AP South" },
],
},
{
key: "tags",
label: "Tags",
type: "multiOption",
options: [
{ value: "gpu", label: "GPU" },
{ value: "spot", label: "Spot" },
{ value: "pci", label: "PCI" },
],
},
{ key: "host", label: "Host", type: "text", placeholder: "api-" },
]);
export function FilterBarOneModel() {
const [filter, setFilter] = useState<Filter>(() =>
createFilter({
id: "hosts",
children: [
createFilterCondition({
id: "region",
field: "region",
operator: "isAnyOf",
value: ["eu-west"],
}),
createFilterCondition({
id: "tags",
field: "tags",
operator: "includesAll",
value: ["gpu", "spot"],
}),
],
}),
);
return (
<div className="grid gap-4">
<FilterBar fields={fields} value={filter} onValueChange={setFilter} />
<p className="text-muted-foreground text-sm">
{describeFilter(filter, fields)}
</p>
<pre className="bg-muted/40 max-h-64 overflow-auto rounded-md border p-3 text-xs">
{JSON.stringify(filter, null, 2)}
</pre>
</div>
);
}| Prop | What it does | Default |
|---|---|---|
fieldsFilterField[] | The fields people can filter by: key, label, type (text, option or multiOption), options, and an optional accessor. | Not set |
value / defaultValueFilter | The filter tree. The root is always a group of conditions and nested groups, as plain JSON. | empty group |
onValueChange(filter: Filter) => void | Called with the next filter after every change. | Not set |
stateFilterState | State from useFilterState, when you also need the filter outside the bar (for a table predicate, the URL or a count). Takes precedence over value props. | Not set |
resultCountnumber | Number of matching rows, announced politely when it changes. | Not set |
addShortcutstring | false | A single key that opens the add menu when focus is not in a text field, for example "f". | false |
collapseAfternumber | On narrow containers, chips after this many collapse behind a +N more button. | 2 |
dataTData[] | Rows to count against on the client. Enables facet counts in option and yes/no pickers, an announced result count and the empty-result Relax suggestion. | Not set |
showImpactboolean | Shows how many rows each chip removes (−42) or, in an OR group, adds (+3), with a screen-reader description. Needs data. | false |
rescueboolean | When nothing matches, names the most restrictive chip and offers Relax, which removes it as one undo step. | true |
evaluateOptions{ now?, timeZone?, weekStartsOn? } | How relative dates resolve. Pass now for server-rendered pages so counts match on the client. | mount time, UTC, Monday |
localestring | Locale for numbers, dates and calendars in chips and editors. | "en-US" |
maxDepthnumber | Group levels allowed, counting the root as 1. Enforced the same way when adding, wrapping and validating. | 3 |
size"sm" | "md" | Chip and action height. | "md" |
labelsPartial<FilterBarLabels> | Every visible and announced string, including operator labels, for localization. | English |
childrenReactNode | Compose FilterBarChips, FilterAddMenu, FilterBarClear and FilterBarUndo yourself. | chips + actions |
| Prop | What it does | Default |
|---|---|---|
key / labelstring | Unique key (also the default row property) and display label. | Not set |
type"text" | "number" | "date" | "boolean" | "option" | "multiOption" | FilterFieldTypeDefinition | Picks the operators, value text and editor. Dates compare calendar days; relative dates stay relative. Pass defineFilterFieldType(...) for your own type with its own operators and editor. | Not set |
options{ value, label, keywords? }[] | Values for option fields. keywords add search synonyms. | Not set |
operators / defaultOperatorstring[] / string | Restrict and order the operators, and pick the one new chips start with. | all for the type |
trueLabel / falseLabel / numberFormatstring / string / Intl.NumberFormatOptions | Value text for boolean and number fields. | "Yes" / "No" / — |
accessor(row) => unknown | Reads the value from a row for client-side evaluation. | row[key] |
description / examplesstring / string[] | Context for people and for the upcoming AI assistant, which uses it to resolve plain-language requests. | Not set |
| Prop | What it does | Default |
|---|---|---|
groupIdstring | Group to edit. Group chips open the editor for their group; FilterBarAdvanced opens it for the whole filter. | root |
useFilterState commandswrapInGroup · unwrapGroup · moveNode · shiftNode · setNegated · setCombinator | The same group operations for your own UI. Each is a single undo step. | Not set |
| Prop | What it does | Default |
|---|---|---|
createFilterPredicate(filter, fields)(row) => boolean | Compiles a filter for client-side filtering. Pass it to DataTable rowFilter. Incomplete chips are skipped. | Not set |
describeFilter(filter, fields)string | One readable sentence, e.g. "Status is any of Open, Blocked, and Title contains "api"". | Not set |
validateFilter(filter, fields)FilterIssue[] | Unknown fields, unavailable operators, missing or wrong-shaped values, and unknown options, with node ids. | Not set |
diffFilter(before, after){ added, removed, changed } | Node-level changes matched by id. | Not set |
useFilterState(options)FilterState | Controlled or uncontrolled state with addNode, updateCondition, removeNode, setCombinator, clear, undo and redo. | Not set |
The chips sit in a toolbar with a single tab stop. Arrow keys move between chip parts, Home and End jump to the ends, and the direction flips in right-to-left layouts. Backspace or Delete removes the focused chip and moves focus to the next one. Cmd or Ctrl+Z undoes and Shift+Cmd or Ctrl+Z redoes. In the group editor, Tab moves through controls and Alt+ArrowUp or ArrowDown moves the focused row. Row actions are named after the row they act on.
Each chip is a group named with its full sentence, such as “Status is any of Open, Blocked”, and the toolbar is described by the sentence for the whole filter. Editors open in popovers that take focus, close with Escape, and return focus to the part that opened them. Pass resultCount to announce the number of matches politely.