Skip to content
UI

Filter panel

beta

A column of filters beside a list of records, with the active filters counted, summarized as chips and cleared from one place.

@zenoxai/ui/patterns/filter-panel Markdown
Filters1 active
From
To
30 indicators
login-verify-40.com
Critical
2026-10-24 08:14:07
login-verify-20.com
Critical
2026-10-24 04:14:07
login-verify-0.com
Critical
2026-10-24 00:14:07
203.0.113.37:443
High
2026-10-23 13:14:07
203.0.113.147:443
High
2026-10-23 09:14:07
203.0.113.7:443
High
2026-10-23 05:14:07
cdn-update-42.com
High
2026-10-22 18:14:07
cdn-update-22.com
High
2026-10-22 14:14:07
cdn-update-2.com
High
2026-10-22 10:14:07
secure-docs-45.com
Critical
2026-10-19 09:14:07
secure-docs-25.com
Critical
2026-10-19 05:14:07
secure-docs-5.com
Critical
2026-10-19 01:14:07
203.0.113.72:443
High
2026-10-18 14:14:07
203.0.113.182:443
High
2026-10-18 10:14:07
203.0.113.42:443
High
2026-10-18 06:14:07
invoice-pay-47.com
High
2026-10-17 19:14:07
invoice-pay-27.com
High
2026-10-17 15:14:07
invoice-pay-7.com
High
2026-10-17 11:14:07
cdn-update-30.com
Critical
2026-10-14 06:14:07
cdn-update-10.com
Critical
2026-10-14 02:14:07
203.0.113.217:443
High
2026-10-13 11:14:07
203.0.113.77:443
High
2026-10-13 07:14:07
login-verify-32.com
High
2026-10-12 16:14:07
login-verify-12.com
High
2026-10-12 12:14:07
invoice-pay-35.com
Critical
2026-10-09 07:14:07
invoice-pay-15.com
Critical
2026-10-09 03:14:07
203.0.113.2:443
High
2026-10-08 12:14:07
203.0.113.112:443
High
2026-10-08 08:14:07
secure-docs-37.com
High
2026-10-07 17:14:07
secure-docs-17.com
High
2026-10-07 13:14:07
function FilterPanelDemo() {
  type Severity = "critical" | "high" | "medium" | "low"
  type Indicator = {
    id: string
    value: string
    type: "domain" | "ip" | "url" | "hash"
    severity: Severity
    compromised: boolean
    tags: string[]
    firstSeen: string
  }

  const indicators = React.useMemo<Indicator[]>(() => {
    const domains = ["login-verify", "secure-docs", "cdn-update", "invoice-pay"]
    const tags = ["phishing", "botnet", "c2", "stealer", "ransomware", "loader"]
    const severities: Severity[] = ["critical", "high", "high", "medium", "low"]
    const types: Indicator["type"][] = ["domain", "ip", "domain", "url", "hash"]
    return Array.from({ length: 48 }, (_, index) => {
      const type = types[index % types.length]!
      const value =
        type === "ip"
          ? `203.0.113.${(index * 7) % 250}:443`
          : type === "hash"
            ? `9f2c${(index * 2654435761).toString(16).slice(0, 12)}`
            : type === "url"
              ? `https://${domains[index % 4]}.net/a/${index}`
              : `${domains[index % 4]}-${index}.com`
      return {
        id: `ioc-${index + 1}`,
        value,
        type,
        severity: severities[index % severities.length]!,
        compromised: index % 3 === 0,
        tags: [tags[index % tags.length]!, tags[(index + 2) % tags.length]!],
        firstSeen: `2026-10-${String(24 - (index % 20)).padStart(2, "0")} ${String(
          (index * 5) % 24
        ).padStart(2, "0")}:14:07`,
      }
    })
  }, [])

  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "severity",
          label: "Severity",
          kind: "multi",
          options: (["critical", "high", "medium", "low"] as const).map(
            (level) => ({
              value: level,
              label: level[0]!.toUpperCase() + level.slice(1),
              icon: (
                <span
                  className={`size-2 rounded-full ${severityBgClass[level]}`}
                />
              ),
            })
          ),
        },
        {
          id: "type",
          label: "Indicator type",
          kind: "single",
          options: [
            { value: "domain", label: "Domain" },
            { value: "ip", label: "IP:Port" },
            { value: "url", label: "URL" },
            { value: "hash", label: "SHA-256" },
          ],
        },
        { id: "compromised", label: "Compromised", kind: "boolean" },
        {
          id: "firstSeen",
          label: "First seen",
          kind: "date",
          presets: [
            { value: "today", label: "Today", days: 1 },
            { value: "7d", label: "Last 7 days", days: 7 },
            { value: "30d", label: "Last 30 days", days: 30 },
          ],
        },
        {
          id: "tags",
          label: "Tags",
          kind: "multi",
          options: [
            "phishing",
            "botnet",
            "c2",
            "stealer",
            "ransomware",
            "loader",
          ].map((tag) => ({ value: tag, label: tag })),
        },
      ]),
    []
  )

  // In an app, the value lives in the URL: filtersFromSearchParams and
  // filtersToSearchParams read and write it.
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    severity: ["critical", "high"],
  })
  const [search, setSearch] = React.useState("")
  const filters = useFilters({ fields, value, onValueChange: setValue })

  // Presets stay relative in the value; resolve them with today's date in
  // the profile's time zone (filterDateToday), fixed here for the example.
  const seen = resolveFilterDate(value.firstSeen, {
    presets: fields[3].presets,
    today: "2026-10-24",
  })
  const seenFrom = seen?.from
  const seenTo = seen?.to

  // A row passes every field except `skip`: counts are disjunctive, so a
  // facet's counts ignore its own selection.
  const matches = React.useCallback(
    (row: Indicator, skip?: string) =>
      (!seenFrom || row.firstSeen.slice(0, 10) >= seenFrom) &&
      (!seenTo || row.firstSeen.slice(0, 10) <= seenTo) &&
      (skip === "severity" ||
        !value.severity ||
        value.severity.includes(row.severity)) &&
      (skip === "type" || !value.type || row.type === value.type) &&
      (skip === "compromised" ||
        value.compromised === undefined ||
        row.compromised === value.compromised) &&
      (skip === "tags" ||
        !value.tags ||
        value.tags.some((tag) => row.tags.includes(tag))) &&
      row.value.toLowerCase().includes(search.trim().toLowerCase()),
    [value, search, seenFrom, seenTo]
  )
  const countsFor = (field: "severity" | "type") => {
    const counts: Record<string, number> = {}
    for (const row of indicators) {
      if (matches(row, field))
        counts[row[field]] = (counts[row[field]] ?? 0) + 1
    }
    return counts
  }
  const rows = React.useMemo(
    () => indicators.filter((row) => matches(row)),
    [indicators, matches]
  )

  const columns = React.useMemo(() => {
    const column = createColumnHelper<Indicator>()
    return [
      column.accessor("value", {
        header: "Indicator",
        meta: { fluid: true, minWidth: 160 },
        cell: (info) => (
          <DataTableText mono strong>
            {info.getValue()}
          </DataTableText>
        ),
      }),
      column.accessor("severity", {
        header: "Severity",
        size: 100,
        cell: (info) => <SeverityBadge level={info.getValue()} />,
      }),
      column.accessor("firstSeen", {
        header: "First seen",
        size: 170,
        sortDescFirst: true,
        cell: (info) => (
          <DataTableText numeric>{info.getValue()}</DataTableText>
        ),
      }),
    ]
  }, [])
  const table = useDataTable({
    data: rows,
    columns,
    getRowId: (row) => row.id,
    defaultSorting: [{ id: "firstSeen", desc: true }],
  })

  return (
    <div className="flex h-[520px] w-full">
      {/* The docs column is narrow, so the sheet starts below 40rem here; an
          app keeps the default 60rem. */}
      <FilterPanelProvider
        filters={filters}
        total={rows.length}
        sheetBelow={40}
      >
        <FilterPanel>
          <FilterSection field="severity">
            <FilterCheckboxList
              field="severity"
              counts={countsFor("severity")}
            />
          </FilterSection>
          <FilterSection field="type">
            <FilterRadioList
              field="type"
              allLabel="All types"
              counts={countsFor("type")}
            />
          </FilterSection>
          <FilterSection field="compromised">
            <FilterRadioList field="compromised" />
          </FilterSection>
          <FilterSection field="firstSeen">
            <FilterDate field="firstSeen" />
          </FilterSection>
          <FilterSection field="tags">
            <FilterMultiSelect field="tags" placeholder="Search tags" />
          </FilterSection>
        </FilterPanel>
        <FilterPanelInset>
          <FilterPanelToolbar>
            <FilterPanelTrigger />
            <InputGroup size="sm" className="max-w-64">
              <InputGroupAddon>
                <SearchIcon />
              </InputGroupAddon>
              <InputGroupInput
                type="search"
                value={search}
                onChange={(event) => setSearch(event.target.value)}
                placeholder="Search indicators"
                aria-label="Search indicators"
              />
            </InputGroup>
            <span className="ms-auto text-sm text-text-secondary tabular-nums">
              {rows.length} indicators
            </span>
          </FilterPanelToolbar>
          <FilterChips />
          <DataTable
            table={table}
            label="Indicators of compromise"
            height="fill"
            total={rows.length}
            resultKey={JSON.stringify({ value, search })}
          />
        </FilterPanelInset>
      </FilterPanelProvider>
    </div>
  )
}

Usage

import {
  FilterCheckboxList,
  FilterChips,
  FilterPanel,
  FilterPanelInset,
  FilterPanelProvider,
  FilterPanelToolbar,
  FilterPanelTrigger,
  FilterSection,
  defineFilterFields,
  filtersFromSearchParams,
  filtersToSearchParams,
  useFilters,
} from "@zenoxai/ui/patterns/filter-panel"

const fields = defineFilterFields([
  { id: "severity", label: "Severity", kind: "multi", options: severityOptions },
  { id: "compromised", label: "Compromised", kind: "boolean" },
])

export function AlertList() {
  const [params, setParams] = useSearchParams()
  const value = React.useMemo(
    () => filtersFromSearchParams(fields, params),
    [params]
  )
  const filters = useFilters({
    fields,
    value,
    onValueChange: (next) =>
      setParams(filtersToSearchParams(fields, next, params), { replace: true }),
  })
  return (
    <FilterPanelProvider filters={filters} open={open} onOpenChange={setOpen}>
      <FilterPanel>
        <FilterSection field="severity">
          <FilterCheckboxList field="severity" counts={severityCounts} />
        </FilterSection>
      </FilterPanel>
      <FilterPanelInset>
        <FilterPanelToolbar>
          <FilterPanelTrigger />
        </FilterPanelToolbar>
        <FilterChips />
        <DataTable
          table={table}
          label="Alerts"
          height="fill"
          total={total}
          resultKey={filtersToSearchParams(fields, value).toString()}
        />
      </FilterPanelInset>
    </FilterPanelProvider>
  )
}

Use a filter panel for a list with four or more facets, or with ranges and dates. For one to three facets, put the same checkbox list in a Popover in the toolbar (example). Decide which filters a list needs with the filtering guideline.

  • Declare the fields once. Each field has an id (its key in the value and the URL), a translated label and a kind: multi (several values, combined with OR), single (one value), boolean (yes, no or no filter), range (a floor, a ceiling or both), date (a preset or custom dates), text (free-text values) or custom (a value the panel cannot show, such as a filter tree from an advanced editor, summarized by your formatValue). Define them outside the component, or in a useMemo when the labels are translated there.
  • The value belongs to the app. useFilters holds no state: pass the value, usually read from the URL with filtersFromSearchParams, and write every change back with filtersToSearchParams, which keeps the other params (search, tab) and replaces nothing else. Values outside a field’s options are kept, since a link may hold one; mark the field strict to drop them. Replace the history entry on a filter change, and debounce the query: arrow keys select radios as they move.
  • Count, chips and Clear come from the fields. The panel header counts the fields with a filter, never the values. A field at its default still counts and has a chip, so a list narrowed by a default never looks complete. Clear returns every field to its default, and reads “Reset” when some field has one. The search box and status tabs are not fields: Clear never touches them.
  • Counts are yours. Pass counts from your facets endpoint, computed disjunctively (a facet’s counts ignore its own selection). Counts are exact, through labels.formatCount. Options keep their given order unless you pass order="count"; hideZero hides values with no results, except selected ones.
  • Dates stay relative. A preset is stored as itself (firstSeen=7d), so a link still means the last seven days tomorrow. Turn the value into dates for your query with resolveFilterDate, passing today from filterDateToday in the profile’s time zone. Custom dates are YYYY-MM-DD as picked; leave one end open for “on or after” or “on or before”.
  • Open and closed. open is yours to persist. Closed, the panel takes no width and its content is inert, and FilterChips shows the active filters under the toolbar. Inside a detail sheet or a dialog, use collapsible="none": the panel is always shown and there is no trigger.
  • Narrow layouts. Below 60rem of provider width the panel moves into a sheet that the trigger opens. The sheet starts closed and is never persisted; its “Show 1,204 results” button (pass total) closes it. sheetBelow moves the breakpoint, or keeps the panel beside the results with false.
  • The table fills the rest. Give the provider a bounded height (a flex column) and the DataTable height="fill". Its total is announced to screen readers when the rows settle after the total or resultKey changes; pass the filters and search as the key, so a filter that leaves the total unchanged is still heard, and sorting or paging is not. Show the number on screen yourself, as in the toolbar above.
  • Texts. Every text of the panel is in labels on the provider, or on a control or FilterChips used outside one, in English by default. The texts of values go in useFilters({ labels }): Yes and No, the “+N” of a chip, ranges (“7 or more”) and formatNumber and formatDate, which the chips and the date buttons share. Pass FilterNumberRange the same locale. Pass your translations, labels with numbers as functions, and keep the objects stable, as with the fields.

Controls

Control Field kind For
FilterCheckboxList multi Several values of a facet, with counts. Five rows, then “Show N more” when three or more would hide; a search box above eight options.
FilterRadioList single, boolean One value, or yes and no. A real radio group, with a first “All” row that removes the filter.
FilterMultiSelect multi Values from a list too long to show (users, entities, tags), as chips in a search input. Pass onInputValueChange to load options from the API.
FilterNumberRange range A floor, a ceiling or both (show). Commits on Enter and blur, not per keystroke or arrow step. For thresholds people pick from a few values, a radio list of presets instead.
FilterDate date Presets first, then custom dates in the Calendar, either end open.
FilterTokenInput text Free-text values as chips: Enter, a comma or leaving the input adds one; the field’s normalize cleans it.
FilterSwitch boolean An on/off filter on a trueOnly field: on keeps the matching records, off removes the filter. Group several in one section with fields and a title.

Examples

Ranges, dates, free text and switches

The other field kinds, in a panel that stays open, with the URL params they write.

Filters2 active

Base score, 0 to 10.

From
To
BR

In the CISA catalog of exploited vulnerabilities.

URL: ?cvss=7..&country=BR

function FilterPanelMoreControls() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "cvss",
          label: "CVSS",
          kind: "range",
          min: 0,
          max: 10,
          step: 0.1,
        },
        {
          id: "published",
          label: "Published",
          kind: "date",
          presets: [
            { value: "7d", label: "Last 7 days", days: 7 },
            { value: "30d", label: "Last 30 days", days: 30 },
          ],
        },
        {
          id: "country",
          label: "Country",
          kind: "text",
          normalize: (country) => country.trim().toUpperCase(),
        },
        {
          id: "kev",
          label: "Known exploited",
          kind: "boolean",
          trueOnly: true,
        },
        {
          id: "internet",
          label: "Internet-facing only",
          kind: "boolean",
          trueOnly: true,
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    cvss: { min: 7 },
    country: ["BR"],
  })
  const filters = useFilters({ fields, value, onValueChange: setValue })
  const params = filtersToSearchParams(fields, value).toString()

  return (
    <div className="flex w-full flex-col gap-3">
      <div className="flex h-[540px] w-72">
        <FilterPanelProvider filters={filters} collapsible="none">
          <FilterPanel>
            <FilterSection field="cvss" description="Base score, 0 to 10.">
              <FilterNumberRange field="cvss" />
            </FilterSection>
            <FilterSection field="published">
              <FilterDate field="published" />
            </FilterSection>
            <FilterSection field="country">
              <FilterTokenInput field="country" placeholder="Add a code" />
            </FilterSection>
            <FilterSection fields={["kev", "internet"]} title="Exposure">
              <FilterSwitch
                field="kev"
                description="In the CISA catalog of exploited vulnerabilities."
              />
              <FilterSwitch field="internet" />
            </FilterSection>
          </FilterPanel>
        </FilterPanelProvider>
      </div>
      <p className="text-sm text-text-secondary">
        URL:{" "}
        <code className="font-mono">{params ? `?${params}` : "(none)"}</code>
      </p>
    </div>
  )
}

On a narrow layout

Below 60rem the panel is a sheet: the trigger opens it and the chips summarize the filters while it is closed.

Filters1 active
Status:Open, Investigating
  • ALR-1043Credential stuffing on vpn.acme.com
  • ALR-1042Beacon to 185.220.101.47
function FilterPanelNarrow() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "status",
          label: "Status",
          kind: "multi",
          options: [
            { value: "open", label: "Open" },
            { value: "investigating", label: "Investigating" },
            { value: "resolved", label: "Resolved" },
          ],
        },
        {
          id: "assigned",
          label: "Assigned to me",
          kind: "boolean",
          trueOnly: true,
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    status: ["open", "investigating"],
  })
  const filters = useFilters({ fields, value, onValueChange: setValue })
  const alerts = [
    {
      id: "ALR-1043",
      title: "Credential stuffing on vpn.acme.com",
      status: "open",
    },
    {
      id: "ALR-1042",
      title: "Beacon to 185.220.101.47",
      status: "investigating",
    },
    {
      id: "ALR-1039",
      title: "Phishing kit on login-verify-acme.com",
      status: "resolved",
    },
  ].filter((alert) => !value.status || value.status.includes(alert.status))

  return (
    <div className="flex h-64 w-full max-w-xl">
      <FilterPanelProvider filters={filters} total={alerts.length}>
        <FilterPanel>
          <FilterSection field="status">
            <FilterCheckboxList field="status" />
          </FilterSection>
          <FilterSection fields={["assigned"]} title="Assignment">
            <FilterSwitch field="assigned" />
          </FilterSection>
        </FilterPanel>
        <FilterPanelInset>
          <FilterPanelToolbar>
            <FilterPanelTrigger />
          </FilterPanelToolbar>
          <FilterChips />
          <ul className="divide-y divide-border text-sm">
            {alerts.map((alert) => (
              <li key={alert.id} className="flex gap-3 px-3 py-2">
                <span className="font-mono text-text-secondary">
                  {alert.id}
                </span>
                <span className="truncate">{alert.title}</span>
              </li>
            ))}
          </ul>
        </FilterPanelInset>
      </FilterPanelProvider>
    </div>
  )
}

Inside a detail view

collapsible="none" for a panel that is always shown, such as the related indicators in a detail sheet.

Related
  • 185.220.101.47:443
  • update-check.net/payload.bin
  • 91.92.240.18:8080
  • 45.137.21.9:4444
function FilterPanelAlwaysOpen() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "relation",
          label: "Relation",
          kind: "single",
          options: [
            { value: "resolves", label: "Resolves to" },
            { value: "downloads", label: "Downloads" },
            { value: "contacts", label: "Contacts" },
          ],
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({})
  const filters = useFilters({ fields, value, onValueChange: setValue })
  const related = [
    { value: "185.220.101.47:443", relation: "resolves" },
    { value: "update-check.net/payload.bin", relation: "downloads" },
    { value: "91.92.240.18:8080", relation: "contacts" },
    { value: "45.137.21.9:4444", relation: "contacts" },
  ].filter((item) => !value.relation || item.relation === value.relation)

  return (
    <div className="flex h-72 w-full max-w-2xl">
      <FilterPanelProvider filters={filters} collapsible="none">
        <FilterPanel title="Related">
          <FilterSection field="relation">
            <FilterRadioList
              field="relation"
              counts={{ resolves: 1, downloads: 1, contacts: 2 }}
            />
          </FilterSection>
        </FilterPanel>
        <FilterPanelInset>
          <ul className="divide-y divide-border text-sm">
            {related.map((item) => (
              <li key={item.value} className="px-3 py-2 font-mono">
                {item.value}
              </li>
            ))}
          </ul>
        </FilterPanelInset>
      </FilterPanelProvider>
    </div>
  )
}

Toolbar facets

For one to three facets, the checkbox list goes in a Popover, with filters passed directly, and FilterChips under the toolbar. returnFocusRef tells FilterChips where focus goes once the last chip is removed.

Status:Open

Alerts with status Open

function FilterPanelToolbarFacet() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "status",
          label: "Status",
          kind: "multi",
          options: [
            { value: "open", label: "Open" },
            { value: "investigating", label: "Investigating" },
            { value: "resolved", label: "Resolved" },
          ],
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    status: ["open"],
  })
  const filters = useFilters({ fields, value, onValueChange: setValue })
  const statusButton = React.useRef<HTMLButtonElement>(null)

  return (
    <div className="flex w-full max-w-xl flex-col overflow-hidden rounded-lg border border-border">
      <div className="flex h-12 items-center gap-2 border-b border-border px-2.5">
        <Popover>
          <PopoverTrigger
            render={
              <Button ref={statusButton} variant="outline" size="sm">
                <ListFilterIcon data-icon="inline-start" />
                Status
              </Button>
            }
          />
          <PopoverContent align="start" className="w-56 p-2">
            <FilterCheckboxList
              field="status"
              filters={filters}
              counts={{ open: 12, investigating: 4, resolved: 31 }}
            />
          </PopoverContent>
        </Popover>
      </div>
      <FilterChips filters={filters} returnFocusRef={statusButton} />
      <p className="px-3 py-6 text-sm text-text-secondary">
        {filters.activeCount > 0
          ? `Alerts with status ${filters.formatValue("status")}`
          : "All alerts"}
      </p>
    </div>
  )
}

Counts that load or fail

While counts refresh, the rows stay in place and the counts dim; on the first load, skeleton rows take their place. When the counts fail, the options stay without counts, with a retry: a group never disappears with the filters it holds.

Filters1 active
function FilterPanelCountStates() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "source",
          label: "Source",
          kind: "multi",
          options: [
            { value: "edr", label: "EDR" },
            { value: "email", label: "Email gateway" },
            { value: "firewall", label: "Firewall" },
          ],
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    source: ["email"],
  })
  const [state, setState] = React.useState("loaded")
  const filters = useFilters({ fields, value, onValueChange: setValue })

  return (
    <div className="flex w-full flex-col gap-3">
      <ToggleGroup
        variant="segmented"
        size="sm"
        value={[state]}
        onValueChange={(next) => {
          if (next[0]) setState(next[0])
        }}
        aria-label="Count state"
      >
        <ToggleGroupItem value="loaded">Loaded</ToggleGroupItem>
        <ToggleGroupItem value="loading">Refreshing</ToggleGroupItem>
        <ToggleGroupItem value="error">Failed</ToggleGroupItem>
      </ToggleGroup>
      <div className="flex h-56 w-72">
        <FilterPanelProvider filters={filters} collapsible="none">
          <FilterPanel>
            <FilterSection field="source">
              <FilterCheckboxList
                field="source"
                counts={{ edr: 1204, email: 318, firewall: 77 }}
                loading={state === "loading"}
                error={state === "error"}
                onRetry={() => setState("loaded")}
              />
            </FilterSection>
          </FilterPanel>
        </FilterPanelProvider>
      </div>
    </div>
  )
}

Options from the API

FilterMultiSelect with onInputValueChange shows the options as they arrive, and the field’s getLabel names a selected value that the current search did not load, such as one from a link.

Filters1 active
function FilterPanelAsyncOptions() {
  const analysts = React.useMemo(
    () =>
      [
        "Dana Whitfield",
        "Marcus Oyelaran",
        "Priya Raman",
        "Tomás Lindqvist",
        "Aiko Tanaka",
        "Rafael Souza",
        "Ingrid Haugen",
        "Kwame Mensah",
      ].map((name, index) => ({ value: `u-${index + 1}`, label: name })),
    []
  )
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "assignee",
          label: "Assignee",
          kind: "multi",
          // Names a selected value the current search did not load.
          getLabel: (id) =>
            analysts.find((analyst) => analyst.value === id)?.label,
        },
      ]),
    [analysts]
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    assignee: ["u-1"],
  })
  const [options, setOptions] = React.useState(analysts.slice(0, 5))
  const [loading, setLoading] = React.useState(false)
  const filters = useFilters({ fields, value, onValueChange: setValue })

  // Stands in for a request to the users API.
  const search = (query: string) => {
    setLoading(true)
    window.setTimeout(() => {
      const needle = query.trim().toLowerCase()
      setOptions(
        analysts.filter((analyst) =>
          analyst.label.toLowerCase().includes(needle)
        )
      )
      setLoading(false)
    }, 300)
  }

  return (
    <div className="flex h-48 w-72">
      <FilterPanelProvider filters={filters} collapsible="none">
        <FilterPanel>
          <FilterSection field="assignee">
            <FilterMultiSelect
              field="assignee"
              options={options}
              loading={loading}
              onInputValueChange={search}
              placeholder="Search analysts"
            />
          </FilterSection>
        </FilterPanel>
      </FilterPanelProvider>
    </div>
  )
}

Accessibility

  • The panel is a region named by its title. Each section’s control is a group named by the section title: a checkbox list is a group, a radio list is the radiogroup itself, with one tab stop and arrow keys between the options.
  • Each option is named by its label; its count and description are its description.
  • The trigger is always named “Filters”, with aria-expanded and aria-controls; the number of active filters is its description. Closing the panel with focus inside it moves focus to the trigger.
  • A folded section with a filter says how many values it holds, and unfolds when its field becomes active after the first render.
  • Removing a chip moves focus to the next chip or to Clear filters. Each remove button names its field (“Remove Severity filter”).
  • DataTable announces total once the rows settle, debounced, in its own status region.
  • In the sheet, the panel is a dialog named by its title: focus moves into it, Escape and “Show results” close it, and focus returns to the trigger. Crossing back past the breakpoint closes it.
  • A range is a group named by its section, with inputs named Minimum and Maximum. Each date button is named by its end and its date (“From Oct 1, 2026”). The free-text input is named by its section. Removing a value or clearing a date keeps focus in the control.

API reference

Generated from the TypeScript source. className, style and children are accepted everywhere and omitted below.

FilterCheckboxList

FilterCheckboxList picks several values of a `multi` field, combined with OR: one checkbox per option, with its count at the end. Long lists show five rows and "Show N more", and a search box above eight options. Put it in a FilterSection, or in a Popover with `filters` for a toolbar facet.

PropTypeDefault
countsPartial<Record<string, number>>

The number of results per value; leave it out where the API has no counts.

-
errorstring | boolean

The counts failed to load: the options stay, without counts, with a retry.

-
field*string

The id of the field.

-
filtersFiltersState

The filters from useFilters, when there is no FilterPanelProvider around.

-
hideZeroboolean

Hides options whose count is 0. A selected option always shows.

false
labelsPartial<FilterPanelLabels>

Replaces the texts, outside a FilterPanelProvider or over its labels.

-
loadingboolean

Counts are loading. With no options yet, skeleton rows; otherwise the rows stay in place and the counts dim.

false
maxVisiblenumber

Rows before "Show N more". Defaults to 5; the list caps only when 3 or more would hide.

5
onRetry(() => void)-
optionsreadonly FilterOption[]

The options to list. Defaults to the field's `options`.

-
order"given" | "count"

`given` (default): the options' own order, for ranked or small sets (severity, priority). `count`: the largest count first, for open sets.

given
searchThresholdnumber

Options above which a search box shows. Defaults to 8.

8

FilterMultiSelect

FilterMultiSelect picks several values of a `multi` field out of a list too long to show, such as users, entities or tags: a search input with the selected values as chips. For a list that loads from the API, pass `onInputValueChange` and the matching `options`; the field's `getLabel` names selected values that are not loaded.

PropTypeDefault
emptyLabelstring

When nothing matches. Defaults to "No matches".

-
field*string

The id of a `multi` field.

-
filtersFiltersState

The filters from useFilters, when there is no FilterPanelProvider around.

-
labelsPartial<FilterPanelLabels>

Replaces the texts, outside a FilterPanelProvider or over its labels.

-
loadingboolean

Options are loading, such as after a keystroke in an async search.

false
onInputValueChange((query: string) => void)

Receives the text typed in the input, to load matching options from the API. When given, the list shows `options` as they are, unfiltered.

-
optionsreadonly FilterOption[]

The options matching the current input. Defaults to the field's `options`.

-
placeholderstring

Already-translated placeholder of the input. Defaults to "Search".

-

FilterRadioList

FilterRadioList picks one value of a `single` field, or yes or no for a `boolean` field, as a real radio group: one tab stop, arrow keys between the options. A first "All" row removes the filter.

PropTypeDefault
allLabelstring

Replaces the label of that row ("All"), such as "All types".

-
countsPartial<Record<string, number>>

The number of results per value; leave it out where the API has no counts.

-
field*string

The id of the field.

-
filtersFiltersState

The filters from useFilters, when there is no FilterPanelProvider around.

-
hideZeroboolean

Hides options whose count is 0. A selected option always shows.

false
labelsPartial<FilterPanelLabels>

Replaces the texts, outside a FilterPanelProvider or over its labels.

-
maxVisiblenumber

Rows before "Show N more". Defaults to 5; the list caps only when 3 or more would hide.

5
optionsreadonly FilterOption[]

The options to list. Defaults to the field's `options`.

-
order"given" | "count"

`given` (default): the options' own order, for ranked or small sets (severity, priority). `count`: the largest count first, for open sets.

given
showAllboolean

Shows a first row that removes the filter. Defaults to true.

true

formatFilterDate

A local Date as `YYYY-MM-DD`.

No props of its own. Accepts the props of the element it renders.

parseFilterDate

A `YYYY-MM-DD` date as a local Date at midnight, or undefined.

No props of its own. Accepts the props of the element it renders.

FilterDate

FilterDate filters a `date` field: presets first ("Last 7 days"), kept relative in the value, then custom dates in the Calendar, where either end may stay open for "on or after" or "on or before". Dates are `YYYY-MM-DD` as picked; resolve presets to dates with resolveFilterDate and the profile's time zone.

PropTypeDefault
allLabelstring

Replaces the label of that row ("Any time").

-
field*string

The id of the field.

-
filtersFiltersState

The filters from useFilters, when there is no FilterPanelProvider around.

-
labelsPartial<FilterPanelLabels>

Replaces the texts, outside a FilterPanelProvider or over its labels.

-
showAllboolean

Shows a first row that removes the filter. Defaults to true.

true

FilterNumberRange

FilterNumberRange filters a `range` field by a floor, a ceiling or both: number inputs that commit on Enter and on blur, never per keystroke or arrow step. For a threshold people pick from a few values (confidence 50, 75, 90), use a FilterRadioList of presets instead.

PropTypeDefault
field*string

The id of the field.

-
filtersFiltersState

The filters from useFilters, when there is no FilterPanelProvider around.

-
formatNumberFormatOptions

Number format of the inputs, such as a fixed number of decimals.

-
labelsPartial<FilterPanelLabels>

Replaces the texts, outside a FilterPanelProvider or over its labels.

-
localeLocalesArgument

The locale the inputs parse and show numbers in. Defaults to "en-US"; pass the app's, the same as `formatNumber` in useFilters.

en-US
show"both" | "min" | "max"

Which ends to offer: `both` (default), only `min` (a floor) or only `max`.

both

FilterSwitch

FilterSwitch turns an on/off filter of a `boolean` field on, or removes it: on keeps only the matching records, off shows all, never only the others. Declare the field `trueOnly`, so a `false` from a link is dropped. For yes, no or all, use FilterRadioList. Several switches can share one FilterSection with `fields`.

PropTypeDefault
descriptionReactNode

A sentence under the label, linked to the switch as its description.

-
field*string

The id of the field.

-
filtersFiltersState

The filters from useFilters, when there is no FilterPanelProvider around.

-
labelstring

Replaces the field's label beside the switch.

-
labelsPartial<FilterPanelLabels>

Replaces the texts, outside a FilterPanelProvider or over its labels.

-

FilterTokenInput

FilterTokenInput filters a `text` field by free-text values, such as country codes or domains: each one becomes a chip when the user presses Enter or a comma, or leaves the input. Duplicates are dropped, and the field's `normalize` cleans each value first.

PropTypeDefault
field*string

The id of the field.

-
filtersFiltersState

The filters from useFilters, when there is no FilterPanelProvider around.

-
labelsPartial<FilterPanelLabels>

Replaces the texts, outside a FilterPanelProvider or over its labels.

-
placeholderstring

Already-translated placeholder of the input. Defaults to "Add a value".

-
suggestionsreadonly string[]

Values to suggest while typing, such as known country codes.

-

FilterChips

FilterChips summarizes the active filters as one removable chip per field, "Severity: Critical, High", with Clear filters at the end. Inside a FilterPanelProvider it shows while the panel is hidden, plus any filter without a section; with the panel open, the panel is the summary. Place it under the FilterPanelToolbar.

PropTypeDefault
filtersFiltersState

The filters from useFilters, when there is no FilterPanelProvider around.

-
labelsPartial<FilterPanelLabels>

Replaces the texts, outside a FilterPanelProvider or over its labels.

-
returnFocusRefRefObject<HTMLElement | null>

Where focus goes once no chip is left, such as the toolbar button that opens the facets. Defaults to the panel's trigger or its first section.

-

FilterPanel

FilterPanel is the column of filters: a header with the title, the number of active fields and Clear, then FilterSections that scroll under it. It sits in a FilterPanelProvider, before the FilterPanelInset. Closed, it takes no width and its content is inert; focus inside it moves to the trigger.

PropTypeDefault
titlestring

Replaces the title from the labels ("Filters").

-

FilterPanelInset

FilterPanelInset is the results side of the layout: the card that holds the FilterPanelToolbar, FilterChips and the DataTable, and takes the width the panel leaves.

No props of its own. Accepts the props of the element it renders.

FilterPanelProvider

FilterPanelProvider lays out a filter panel beside the results of a list and holds what its parts share: the filters from useFilters, whether the panel is open, and the texts. Put a FilterPanel and a FilterPanelInset inside it. Give it a bounded height (a flex column) when the table inside fills the space with `height="fill"`. Narrower than 60rem, the panel moves into a sheet that the trigger opens; the sheet starts closed and is never persisted, so the open preference applies only to the panel beside the results.

PropTypeDefault
collapsible"none" | "offcanvas"

`offcanvas` (default): the trigger hides and shows the panel. `none`: the panel is always shown and there is no trigger, for a panel inside a detail sheet or a dialog.

offcanvas
defaultOpenboolean

The starting `open` when uncontrolled. Defaults to true.

true
filters*FiltersState

The filters from useFilters.

-
labelsPartial<FilterPanelLabels>

Replaces the default English texts. Keep the object stable: define it outside the component or in a useMemo, as with the fields.

-
onOpenChange((open: boolean) => void)-
openboolean

Whether the panel is shown. Controlled; the app persists it.

-
sheetBelownumber | false

The provider width, in rem, below which the panel moves into a sheet. Defaults to 60 (960px), which the CSS applies from the first paint; any other value applies once the width is measured. `false` keeps the panel beside the results.

60
totalnumber

The number of results, for the "Show 1,204 results" button of the sheet.

-

FilterPanelToolbar

FilterPanelToolbar is the 48px band at the top of the FilterPanelInset, level with the panel's header: the trigger first, then the search, toolbar facets and the Columns menu.

No props of its own. Accepts the props of the element it renders.

FilterPanelTrigger

FilterPanelTrigger hides and shows the FilterPanel. Its name stays "Filters"; while the panel is closed it shows the number of active fields, which screen readers hear as its description. Place it first in the toolbar, and in the bulk action bar when that replaces the toolbar. It renders nothing when the panel cannot collapse.

PropTypeDefault
focusableWhenDisabledboolean

Whether the button should be focusable when disabled.

false
nativeButtonboolean

Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).

true
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component's HTML element with a different tag, or compose it with another component.

-
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null

Height: `xs` 24px, `sm` 28px, `default` 32px, `lg` 36px. The `icon-*` sizes are square buttons of the same heights for a single icon. The icon follows the size name: 12px at `xs`, 14px at `sm`, 16px at `default` and `lg`.

-
variant"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null

Visual weight. `default` is the filled primary action, one per view; `outline` and `secondary` are for the other actions; `ghost` has no fill until hover, for toolbars and icon buttons; `destructive` is for delete and other irreversible actions; `link` looks like inline text.

-

FilterSection

FilterSection is one field in a FilterPanel: a title that folds and unfolds the control under it. A folded section with a filter shows the number of values beside its title, and it unfolds when its field becomes active after the first render, such as on back and forward.

PropTypeDefault
defaultOpenboolean

Open on first render. Defaults to true.

true
descriptionReactNode

One or two sentences under the title: what the counts mean, a unit.

-
fieldstring

The id of the field the section filters.

-
fieldsreadonly string[]

The ids of several fields under one title, such as a few switches. Give the section a `title`.

-
titlestring

Replaces the field's label as the title.

-

useFilterControl

The filters and labels a control works with: from the `filters` prop when it is given (a toolbar facet outside any panel), else from the provider.

No props of its own. Accepts the props of the element it renders.

useFilterPanel

useFilterPanel returns the state of the nearest FilterPanelProvider, or null outside one.

No props of its own. Accepts the props of the element it renders.

useFilterSection

The section a control sits in, which names its group.

No props of its own. Accepts the props of the element it renders.

defineFilterFields

defineFilterFields returns its argument, typed with the literal ids of the fields, so the filters value is typed per field without `as const`.

No props of its own. Accepts the props of the element it renders.

filterDateToday

filterDateToday returns today's date as `YYYY-MM-DD` in a time zone, such as the profile's, for resolveFilterDate. Defaults to the browser's zone.

No props of its own. Accepts the props of the element it renders.

filtersFromSearchParams

filtersFromSearchParams reads the filters back from URL params written by filtersToSearchParams. A missing param gives the field's default. Values outside a field's `options` are kept, since a link may hold one, unless the field is `strict`.

No props of its own. Accepts the props of the element it renders.

filtersToSearchParams

filtersToSearchParams writes the filters to URL params, readable and stable: one param per value of a multi field (`severity=critical&severity=high`), `true`/`false` for booleans, nothing for a field at its default, and an empty param for a field with a default and no filter. Params that are not fields, such as a search or a tab, are kept.

No props of its own. Accepts the props of the element it renders.

resolveFilterDate

resolveFilterDate turns a date filter into the dates to query: a preset with `days` becomes the range that ends today, custom dates pass through. Pass today from filterDateToday with the profile's time zone. A preset without `days` resolves to nothing: the app maps it itself.

No props of its own. Accepts the props of the element it renders.

useFilters

useFilters derives everything a filter panel shows from fields declared once and the current value: the number of active fields, one chip per field, clear and reset. It holds no state of its own: the value comes from the app (usually the URL) and every change goes to `onValueChange`. Pass the result to FilterPanelProvider.

PropTypeDefault
fields*readonly FilterField<string>[]

The fields, declared once (outside the component or in a useMemo).

-
labelsPartial<FilterValueLabels>

Texts of chip values: yes and no, ranges, dates, the overflow. Keep the object stable, as with the fields.

-
onValueChange*(value: FilterValues<TFields>) => void

Receives the next filters; write them to the URL with filtersToSearchParams.

-
value*FilterValues<TFields>

The current filters, usually read from the URL with filtersFromSearchParams.

-