Filter panel
betaA 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 Markdownlogin-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 translatedlabeland akind: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) orcustom(a value the panel cannot show, such as a filter tree from an advanced editor, summarized by yourformatValue). Define them outside the component, or in auseMemowhen the labels are translated there. - The value belongs to the app.
useFiltersholds no state: pass the value, usually read from the URL withfiltersFromSearchParams, and write every change back withfiltersToSearchParams, which keeps the other params (search, tab) and replaces nothing else. Values outside a field’soptionsare kept, since a link may hold one; mark the fieldstrictto 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
countsfrom your facets endpoint, computed disjunctively (a facet’s counts ignore its own selection). Counts are exact, throughlabels.formatCount. Options keep their given order unless you passorder="count";hideZerohides 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 withresolveFilterDate, passing today fromfilterDateTodayin the profile’s time zone. Custom dates areYYYY-MM-DDas picked; leave one end open for “on or after” or “on or before”. - Open and closed.
openis 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, usecollapsible="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.sheetBelowmoves the breakpoint, or keeps the panel beside the results withfalse. - The table fills the rest. Give the provider a bounded height (a flex column) and the
DataTable
height="fill". Itstotalis announced to screen readers when the rows settle after the total orresultKeychanges; 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
labelson the provider, or on a control or FilterChips used outside one, in English by default. The texts of values go inuseFilters({ labels }): Yes and No, the “+N” of a chip, ranges (“7 or more”) andformatNumberandformatDate, which the chips and the date buttons share. Pass FilterNumberRange the samelocale. 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.
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.
- 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.
- 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.
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.
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.
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 theradiogroupitself, 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-expandedandaria-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
totalonce 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.
| Prop | Type | Default |
|---|---|---|
counts | Partial<Record<string, number>>The number of results per value; leave it out where the API has no counts. | - |
error | string | booleanThe counts failed to load: the options stay, without counts, with a retry. | - |
field* | stringThe id of the field. | - |
filters | FiltersStateThe filters from useFilters, when there is no FilterPanelProvider around. | - |
hideZero | booleanHides options whose count is 0. A selected option always shows. | false |
labels | Partial<FilterPanelLabels>Replaces the texts, outside a FilterPanelProvider or over its labels. | - |
loading | booleanCounts are loading. With no options yet, skeleton rows; otherwise the rows stay in place and the counts dim. | false |
maxVisible | numberRows before "Show N more". Defaults to 5; the list caps only when 3 or more would hide. | 5 |
onRetry | (() => void) | - |
options | readonly 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 |
searchThreshold | numberOptions 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.
| Prop | Type | Default |
|---|---|---|
emptyLabel | stringWhen nothing matches. Defaults to "No matches". | - |
field* | stringThe id of a `multi` field. | - |
filters | FiltersStateThe filters from useFilters, when there is no FilterPanelProvider around. | - |
labels | Partial<FilterPanelLabels>Replaces the texts, outside a FilterPanelProvider or over its labels. | - |
loading | booleanOptions 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. | - |
options | readonly FilterOption[]The options matching the current input. Defaults to the field's `options`. | - |
placeholder | stringAlready-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.
| Prop | Type | Default |
|---|---|---|
allLabel | stringReplaces the label of that row ("All"), such as "All types". | - |
counts | Partial<Record<string, number>>The number of results per value; leave it out where the API has no counts. | - |
field* | stringThe id of the field. | - |
filters | FiltersStateThe filters from useFilters, when there is no FilterPanelProvider around. | - |
hideZero | booleanHides options whose count is 0. A selected option always shows. | false |
labels | Partial<FilterPanelLabels>Replaces the texts, outside a FilterPanelProvider or over its labels. | - |
maxVisible | numberRows before "Show N more". Defaults to 5; the list caps only when 3 or more would hide. | 5 |
options | readonly 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 |
showAll | booleanShows 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.
| Prop | Type | Default |
|---|---|---|
allLabel | stringReplaces the label of that row ("Any time"). | - |
field* | stringThe id of the field. | - |
filters | FiltersStateThe filters from useFilters, when there is no FilterPanelProvider around. | - |
labels | Partial<FilterPanelLabels>Replaces the texts, outside a FilterPanelProvider or over its labels. | - |
showAll | booleanShows 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.
| Prop | Type | Default |
|---|---|---|
field* | stringThe id of the field. | - |
filters | FiltersStateThe filters from useFilters, when there is no FilterPanelProvider around. | - |
format | NumberFormatOptionsNumber format of the inputs, such as a fixed number of decimals. | - |
labels | Partial<FilterPanelLabels>Replaces the texts, outside a FilterPanelProvider or over its labels. | - |
locale | LocalesArgumentThe 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`.
| Prop | Type | Default |
|---|---|---|
description | ReactNodeA sentence under the label, linked to the switch as its description. | - |
field* | stringThe id of the field. | - |
filters | FiltersStateThe filters from useFilters, when there is no FilterPanelProvider around. | - |
label | stringReplaces the field's label beside the switch. | - |
labels | Partial<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.
| Prop | Type | Default |
|---|---|---|
field* | stringThe id of the field. | - |
filters | FiltersStateThe filters from useFilters, when there is no FilterPanelProvider around. | - |
labels | Partial<FilterPanelLabels>Replaces the texts, outside a FilterPanelProvider or over its labels. | - |
placeholder | stringAlready-translated placeholder of the input. Defaults to "Add a value". | - |
suggestions | readonly 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.
| Prop | Type | Default |
|---|---|---|
filters | FiltersStateThe filters from useFilters, when there is no FilterPanelProvider around. | - |
labels | Partial<FilterPanelLabels>Replaces the texts, outside a FilterPanelProvider or over its labels. | - |
returnFocusRef | RefObject<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.
| Prop | Type | Default |
|---|---|---|
title | stringReplaces 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.
| Prop | Type | Default |
|---|---|---|
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 |
defaultOpen | booleanThe starting `open` when uncontrolled. Defaults to true. | true |
filters* | FiltersStateThe filters from useFilters. | - |
labels | Partial<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) | - |
open | booleanWhether the panel is shown. Controlled; the app persists it. | - |
sheetBelow | number | falseThe 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 |
total | numberThe 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.
| Prop | Type | Default |
|---|---|---|
focusableWhenDisabled | booleanWhether the button should be focusable when disabled. | false |
nativeButton | booleanWhether 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 |
render | ReactElement<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" | nullHeight: `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" | nullVisual 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.
| Prop | Type | Default |
|---|---|---|
defaultOpen | booleanOpen on first render. Defaults to true. | true |
description | ReactNodeOne or two sentences under the title: what the counts mean, a unit. | - |
field | stringThe id of the field the section filters. | - |
fields | readonly string[]The ids of several fields under one title, such as a few switches. Give the section a `title`. | - |
title | stringReplaces 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.
| Prop | Type | Default |
|---|---|---|
fields* | readonly FilterField<string>[]The fields, declared once (outside the component or in a useMemo). | - |
labels | Partial<FilterValueLabels>Texts of chip values: yes and no, ranges, dates, the overflow. Keep the object stable, as with the fields. | - |
onValueChange* | (value: FilterValues<TFields>) => voidReceives the next filters; write them to the URL with filtersToSearchParams. | - |
value* | FilterValues<TFields>The current filters, usually read from the URL with filtersFromSearchParams. | - |