Skip to content
Dethink Components

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 components

    Accessibility 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.

Usage
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.

5 results
Labels
DT-412API latency spike on /searchBlockedurgentbug, apiAda52026-09-29
DT-418Billing export drops tax linesOpenhighbug, billingLin32026-09-24
DT-425Usage dashboard for workspacesOpenmediumfeature—132026-09-02
DT-433Audit log retention settingsOpenlowfeature, authSam52026-09-15
DT-437Webhook retries back off too fastBlockedhighapi—32026-09-28
Show sourceexamples/filter-bar/issue-table.tsx
examples/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

Show sourceexamples/filter-bar/groups.tsx
examples/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
examples/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
examples/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>
  );
}
FilterBar props
PropWhat it doesDefault
fieldsFilterField[]The fields people can filter by: key, label, type (text, option or multiOption), options, and an optional accessor.Not set
value / defaultValueFilterThe filter tree. The root is always a group of conditions and nested groups, as plain JSON.empty group
onValueChange(filter: Filter) => voidCalled with the next filter after every change.Not set
stateFilterStateState 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
resultCountnumberNumber of matching rows, announced politely when it changes.Not set
addShortcutstring | falseA single key that opens the add menu when focus is not in a text field, for example "f".false
collapseAfternumberOn 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
showImpactbooleanShows how many rows each chip removes (−42) or, in an OR group, adds (+3), with a screen-reader description. Needs data.false
rescuebooleanWhen 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
localestringLocale for numbers, dates and calendars in chips and editors."en-US"
maxDepthnumberGroup 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
childrenReactNodeCompose FilterBarChips, FilterAddMenu, FilterBarClear and FilterBarUndo yourself.chips + actions
FilterField
PropWhat it doesDefault
key / labelstringUnique key (also the default row property) and display label.Not set
type"text" | "number" | "date" | "boolean" | "option" | "multiOption" | FilterFieldTypeDefinitionPicks 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[] / stringRestrict and order the operators, and pick the one new chips start with.all for the type
trueLabel / falseLabel / numberFormatstring / string / Intl.NumberFormatOptionsValue text for boolean and number fields."Yes" / "No" / —
accessor(row) => unknownReads 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
FilterGroupEditor
PropWhat it doesDefault
groupIdstringGroup to edit. Group chips open the editor for their group; FilterBarAdvanced opens it for the whole filter.root
useFilterState commandswrapInGroup · unwrapGroup · moveNode · shiftNode · setNegated · setCombinatorThe same group operations for your own UI. Each is a single undo step.Not set
Filter core
PropWhat it doesDefault
createFilterPredicate(filter, fields)(row) => booleanCompiles a filter for client-side filtering. Pass it to DataTable rowFilter. Incomplete chips are skipped.Not set
describeFilter(filter, fields)stringOne 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)FilterStateControlled 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.