Skip to content

Filter

Filter is the building kit for list filtering: a trigger button, a popup where the user builds a selection, and removable chips for filters already applied. It holds no filter state — what is selected, what “apply” and “clear” mean, and how results are fetched all belong to the consuming page.

Code
import {
Checkbox,
Filter,
FilterApply,
FilterButton,
FilterChip,
FilterChipRemove,
FilterChips,
FilterClearAll,
FilterContent,
FilterFooter,
FilterHeader,
FilterNav,
FilterNavFooter,
FilterNavItem,
FilterOption,
FilterOptionList,
FilterPane,
FilterPaneBack,
FilterPaneClear,
FilterPaneFooter,
FilterPaneHeader,
FilterPaneTitle,
} from "@falcon/ui-kit";
import { useState } from "react";
const regions = ["Midwest", "Great Plains", "Delta", "Southeast", "Pacific"];
export function Example() {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<string[]>([]);
const [applied, setApplied] = useState<string[]>([]);
function toggle(region: string, checked: boolean) {
setDraft(checked ? [...draft, region] : draft.filter((r) => r !== region));
}
function apply() {
setApplied(draft);
setOpen(false);
}
function clearDraft() {
setDraft([]);
}
function clearApplied() {
setApplied([]);
setDraft([]);
}
const draftCount = draft.length > 0 ? 1 : 0;
return (
<div className="tw:flex tw:flex-col tw:items-start tw:gap-2">
<Filter
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setDraft(applied);
}
setOpen(nextOpen);
}}
>
<FilterButton>Filter</FilterButton>
<FilterContent>
<FilterHeader />
<FilterNav>
<FilterNavItem active applied={draft.length > 0} values={draft}>
Region
</FilterNavItem>
</FilterNav>
<FilterPane>
<FilterPaneHeader>
<FilterPaneBack />
<FilterPaneTitle>Region</FilterPaneTitle>
{draft.length > 0 && <FilterPaneClear onClick={clearDraft} />}
</FilterPaneHeader>
<FilterOptionList>
{regions.map((region) => (
<FilterOption key={region}>
<Checkbox
checked={draft.includes(region)}
onCheckedChange={(checked) => toggle(region, checked)}
/>
{region}
</FilterOption>
))}
</FilterOptionList>
<FilterFooter>
<FilterClearAll onClick={clearDraft} />
<FilterApply count={draftCount} onClick={apply} />
</FilterFooter>
<FilterPaneFooter onClear={clearDraft} />
</FilterPane>
<FilterNavFooter>
<FilterClearAll onClick={clearDraft}>Clear filters</FilterClearAll>
<FilterApply count={draftCount} onClick={apply} />
</FilterNavFooter>
</FilterContent>
</Filter>
{applied.length > 0 && (
<FilterChips>
<FilterChip category="Region" values={applied}>
<FilterChipRemove
aria-label="Remove region filter"
onClick={clearApplied}
/>
</FilterChip>
</FilterChips>
)}
</div>
);
}

Use the following composition to build a Filter:

Filter
├── FilterButton
└── FilterContent
├── FilterHeader
├── FilterNav
│ ├── FilterNavSaved
│ └── FilterNavItem
├── FilterPane
│ ├── FilterPaneHeader
│ │ ├── FilterPaneBack
│ │ ├── FilterPaneTitle
│ │ └── FilterPaneClear
│ ├── FilterSearch
│ ├── FilterOptionList
│ │ └── FilterOption
│ ├── FilterPaneContent
│ ├── FilterFooter
│ │ ├── FilterClearAll
│ │ └── FilterApply
│ └── FilterPaneFooter
└── FilterNavFooter
├── FilterClearAll
└── FilterApply

A pane body is either a FilterOptionList of FilterOption rows — with a Checkbox for multi-select or RadioGroup items for one-of-many — or a FilterPaneContent of free-form controls (Select, Input). A “Select all” row is a FilterOption with a Checkbox in its indeterminate state. Build pane bodies from Falcon components only; when migrating an existing filter, rebuild each pane from these blocks instead of pasting its old markup — foreign markup drags its own typography, spacing, and colors into the popup.

Applied filters render outside the popup, and FilterSave opens a FilterSaveDialog:

FilterChips
├── FilterChip
│ └── FilterChipRemove
└── FilterSave

The Saved pane lists FilterSavedItem rows inside a FilterOptionList:

FilterSavedItem
├── FilterSavedItemButton
├── FilterSavedItemEdit
└── FilterSavedItemDelete

A single category with a draft selection: choices accumulate in local state and take effect on Apply, and the applied category renders as one FilterChip whose remove clears it. A single-category filter renders its FilterPane unconditionally, with no category state to manage.

Below the sm breakpoint the popup becomes two screens: the category nav and the open pane. Wire the same apply-and-close onClick into both footers you render yourself — the pane’s FilterFooter fires it on desktop, FilterNavFooter on the mobile nav screen.

Code
import {
Checkbox,
Filter,
FilterApply,
FilterButton,
FilterChip,
FilterChipRemove,
FilterChips,
FilterClearAll,
FilterContent,
FilterFooter,
FilterHeader,
FilterNav,
FilterNavFooter,
FilterNavItem,
FilterOption,
FilterOptionList,
FilterPane,
FilterPaneBack,
FilterPaneClear,
FilterPaneFooter,
FilterPaneHeader,
FilterPaneTitle,
} from "@falcon/ui-kit";
import { useState } from "react";
const regions = ["Midwest", "Great Plains", "Delta", "Southeast", "Pacific"];
export function Example() {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<string[]>([]);
const [applied, setApplied] = useState<string[]>([]);
function toggle(region: string, checked: boolean) {
setDraft(checked ? [...draft, region] : draft.filter((r) => r !== region));
}
function apply() {
setApplied(draft);
setOpen(false);
}
function clearDraft() {
setDraft([]);
}
function clearApplied() {
setApplied([]);
setDraft([]);
}
const draftCount = draft.length > 0 ? 1 : 0;
return (
<div className="tw:flex tw:flex-col tw:items-start tw:gap-2">
<Filter
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setDraft(applied);
}
setOpen(nextOpen);
}}
>
<FilterButton>Filter</FilterButton>
<FilterContent>
<FilterHeader />
<FilterNav>
<FilterNavItem active applied={draft.length > 0} values={draft}>
Region
</FilterNavItem>
</FilterNav>
<FilterPane>
<FilterPaneHeader>
<FilterPaneBack />
<FilterPaneTitle>Region</FilterPaneTitle>
{draft.length > 0 && <FilterPaneClear onClick={clearDraft} />}
</FilterPaneHeader>
<FilterOptionList>
{regions.map((region) => (
<FilterOption key={region}>
<Checkbox
checked={draft.includes(region)}
onCheckedChange={(checked) => toggle(region, checked)}
/>
{region}
</FilterOption>
))}
</FilterOptionList>
<FilterFooter>
<FilterClearAll onClick={clearDraft} />
<FilterApply count={draftCount} onClick={apply} />
</FilterFooter>
<FilterPaneFooter onClear={clearDraft} />
</FilterPane>
<FilterNavFooter>
<FilterClearAll onClick={clearDraft}>Clear filters</FilterClearAll>
<FilterApply count={draftCount} onClick={apply} />
</FilterNavFooter>
</FilterContent>
</Filter>
{applied.length > 0 && (
<FilterChips>
<FilterChip category="Region" values={applied}>
<FilterChipRemove
aria-label="Remove region filter"
onClick={clearApplied}
/>
</FilterChip>
</FilterChips>
)}
</div>
);
}

Saved filters are optional. When a filter offers them, FilterNav leads with FilterNavSaved, even when there is only one other category. The saved list itself lives in the consumer — Falcon renders it but does not persist it.

With more than one category, FilterPaneBack returns to the nav without clearing which category was open, so keep that state as is: FilterNavItem still shows it active, and resizing back up to desktop shows that pane instead of an empty second column.

Code
import {
Checkbox,
Filter,
FilterApply,
FilterButton,
FilterChip,
FilterChipRemove,
FilterChips,
FilterClearAll,
FilterContent,
FilterFooter,
FilterHeader,
FilterNav,
FilterNavFooter,
FilterNavItem,
FilterNavSaved,
FilterOption,
FilterOptionList,
FilterPane,
FilterPaneBack,
FilterPaneClear,
FilterPaneFooter,
FilterPaneHeader,
FilterPaneTitle,
FilterSave,
FilterSaveDialog,
FilterSavedItem,
FilterSavedItemButton,
FilterSavedItemDelete,
FilterSavedItemEdit,
} from "@falcon/ui-kit";
import { useRef, useState } from "react";
const regions = ["Midwest", "Great Plains", "Delta", "Southeast", "Pacific"];
interface SavedFilter {
id: number;
name: string;
regions: string[];
}
export function Example() {
const [open, setOpen] = useState(false);
const [category, setCategory] = useState("Region");
const [draft, setDraft] = useState<string[]>([]);
const [applied, setApplied] = useState<string[]>([]);
const [saved, setSaved] = useState<SavedFilter[]>([]);
const [saveOpen, setSaveOpen] = useState(false);
const [editing, setEditing] = useState<SavedFilter | null>(null);
const nextId = useRef(1);
function toggle(region: string, checked: boolean) {
setDraft(checked ? [...draft, region] : draft.filter((r) => r !== region));
}
function apply() {
setApplied(draft);
setOpen(false);
}
function clearDraft() {
setDraft([]);
}
function clearApplied() {
setApplied([]);
setDraft([]);
}
const draftCount = draft.length > 0 ? 1 : 0;
const footer = (
<FilterFooter>
<FilterClearAll onClick={clearDraft} />
<FilterApply count={draftCount} onClick={apply} />
</FilterFooter>
);
const mobileFooter = <FilterPaneFooter onClear={clearDraft} />;
return (
<div className="tw:flex tw:flex-col tw:items-start tw:gap-2">
<Filter
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setDraft(applied);
}
setOpen(nextOpen);
}}
>
<FilterButton>Filter</FilterButton>
<FilterContent>
<FilterHeader />
<FilterNav>
<FilterNavSaved
active={category === "Saved"}
applied={saved.length > 0}
onClick={() => setCategory("Saved")}
/>
<FilterNavItem
active={category === "Region"}
applied={draft.length > 0}
values={draft}
onClick={() => setCategory("Region")}
>
Region
</FilterNavItem>
</FilterNav>
{category === "Saved" && (
<FilterPane>
<FilterPaneHeader>
<FilterPaneBack />
<FilterPaneTitle>Saved</FilterPaneTitle>
</FilterPaneHeader>
{saved.length === 0 ? (
<p className="tw:flex-1 tw:px-4 tw:py-2 tw:text-[0.8rem] tw:text-muted-foreground">
No saved filters yet. Use Save next to your active filter
chips to add one.
</p>
) : (
<FilterOptionList>
{saved.map((filter) => (
<FilterSavedItem key={filter.id}>
<FilterSavedItemButton
onClick={() => {
setApplied(filter.regions);
setDraft(filter.regions);
setOpen(false);
}}
>
{filter.name}
</FilterSavedItemButton>
<FilterSavedItemEdit
aria-label={`Edit ${filter.name}`}
onClick={() => setEditing(filter)}
/>
<FilterSavedItemDelete
aria-label={`Delete ${filter.name}`}
onClick={() =>
setSaved(saved.filter((f) => f.id !== filter.id))
}
/>
</FilterSavedItem>
))}
</FilterOptionList>
)}
{footer}
{mobileFooter}
</FilterPane>
)}
{category === "Region" && (
<FilterPane>
<FilterPaneHeader>
<FilterPaneBack />
<FilterPaneTitle>Region</FilterPaneTitle>
{draft.length > 0 && <FilterPaneClear onClick={clearDraft} />}
</FilterPaneHeader>
<FilterOptionList>
{regions.map((region) => (
<FilterOption key={region}>
<Checkbox
checked={draft.includes(region)}
onCheckedChange={(checked) => toggle(region, checked)}
/>
{region}
</FilterOption>
))}
</FilterOptionList>
{footer}
{mobileFooter}
</FilterPane>
)}
<FilterNavFooter>
<FilterClearAll onClick={clearDraft}>Clear filters</FilterClearAll>
<FilterApply count={draftCount} onClick={apply} />
</FilterNavFooter>
</FilterContent>
</Filter>
{applied.length > 0 && (
<>
<FilterChips>
<FilterChip category="Region" values={applied}>
<FilterChipRemove
aria-label="Remove region filter"
onClick={clearApplied}
/>
</FilterChip>
<FilterSave onClick={() => setSaveOpen(true)} />
</FilterChips>
<FilterSaveDialog
open={saveOpen}
onOpenChange={setSaveOpen}
onSave={(name) => {
setSaved([
...saved,
{ id: nextId.current++, name, regions: applied },
]);
}}
>
<FilterChips>
<FilterChip category="Region" values={applied} />
</FilterChips>
</FilterSaveDialog>
</>
)}
<FilterSaveDialog
open={editing !== null}
onOpenChange={(isOpen) => {
if (!isOpen) setEditing(null);
}}
onSave={(name) => {
setSaved(
saved.map((f) => (f.id === editing?.id ? { ...f, name } : f)),
);
}}
title="Edit Filter"
submitLabel="Edit Filter"
defaultName={editing?.name}
>
<FilterChips>
<FilterChip category="Region" values={editing?.regions ?? []} />
</FilterChips>
</FilterSaveDialog>
</div>
);
}

All three pane kinds behind one FilterNav, with a draft selection that commits on Apply. Each category with a selection counts as one filter: the Apply button shows the live count, the category’s FilterNavItem gets applied, and applied categories render as chips that clear one category each.

Code
import {
Button,
ButtonGroup,
Checkbox,
CircleQuestionMarkIcon,
Filter,
FilterApply,
FilterButton,
FilterChip,
FilterChipRemove,
FilterChips,
FilterClearAll,
FilterContent,
FilterFooter,
FilterHeader,
FilterNav,
FilterNavFooter,
FilterNavItem,
FilterNavSaved,
FilterOption,
FilterOptionList,
FilterPane,
FilterPaneBack,
FilterPaneClear,
FilterPaneContent,
FilterPaneFooter,
FilterPaneHeader,
FilterPaneTitle,
FilterSave,
FilterSaveDialog,
FilterSavedItem,
FilterSavedItemButton,
FilterSavedItemDelete,
FilterSavedItemEdit,
FilterSearch,
Label,
PlusIcon,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Trash2Icon,
} from "@falcon/ui-kit";
import { Fragment, useRef, useState } from "react";
const statuses = ["Any status", "Active", "Inactive"];
const regions = [
"Midwest",
"Great Plains",
"Delta",
"Southeast",
"Northeast",
"Pacific",
"Mountain",
"Southwest",
"Corn Belt",
"Wheat Belt",
"Cotton Belt",
"Lake States",
];
const actions = ["Bought", "Didn't buy", "Booked", "Didn't book"];
interface Condition {
id: number;
action: string;
operator: string;
}
interface Selection {
status: string;
regions: string[];
conditions: Condition[];
}
interface SavedFilter {
id: number;
name: string;
selection: Selection;
}
function newCondition(id: number): Condition {
return { id, action: "", operator: "and" };
}
function emptySelection(conditionId: number): Selection {
return {
status: "Any status",
regions: [],
conditions: [newCondition(conditionId)],
};
}
function countFilters(selection: Selection) {
return (
(selection.status === "Any status" ? 0 : 1) +
(selection.regions.length > 0 ? 1 : 0) +
(selection.conditions.some((c) => c.action) ? 1 : 0)
);
}
function SelectionChips({
selection,
onRemove,
children,
}: {
selection: Selection;
onRemove?: (category: keyof Selection) => void;
children?: React.ReactNode;
}) {
const conditions = selection.conditions.filter((c) => c.action);
return (
<FilterChips>
{selection.status !== "Any status" && (
<FilterChip category="Status" values={[selection.status]}>
{onRemove && (
<FilterChipRemove
aria-label="Remove status filter"
onClick={() => onRemove("status")}
/>
)}
</FilterChip>
)}
{selection.regions.length > 0 && (
<FilterChip category="Region" values={selection.regions}>
{onRemove && (
<FilterChipRemove
aria-label="Remove region filter"
onClick={() => onRemove("regions")}
/>
)}
</FilterChip>
)}
{conditions.length > 0 && (
<FilterChip
category="Sales and bookings"
values={conditions.map((c) => c.action)}
>
{onRemove && (
<FilterChipRemove
aria-label="Remove sales and bookings filter"
onClick={() => onRemove("conditions")}
/>
)}
</FilterChip>
)}
{children}
</FilterChips>
);
}
export function Example() {
const [open, setOpen] = useState(false);
const [category, setCategory] = useState("Status");
const [search, setSearch] = useState("");
const nextId = useRef(1);
const [draft, setDraft] = useState(emptySelection(0));
const [applied, setApplied] = useState(draft);
const [saved, setSaved] = useState<SavedFilter[]>([]);
const [saveOpen, setSaveOpen] = useState(false);
const [editing, setEditing] = useState<SavedFilter | null>(null);
const draftCount = countFilters(draft);
const visibleRegions = regions.filter((region) =>
region.toLowerCase().includes(search.trim().toLowerCase()),
);
function updateCondition(id: number, patch: Partial<Condition>) {
setDraft({
...draft,
conditions: draft.conditions.map((c) =>
c.id === id ? { ...c, ...patch } : c,
),
});
}
function clearDraft() {
setDraft(emptySelection(nextId.current++));
}
function clearStatus() {
setDraft({ ...draft, status: "Any status" });
}
function clearRegions() {
setDraft({ ...draft, regions: [] });
}
function clearConditions() {
setDraft({ ...draft, conditions: [newCondition(nextId.current++)] });
}
function removeCategory(removed: keyof Selection) {
const next = { ...applied };
if (removed === "status") next.status = "Any status";
if (removed === "regions") next.regions = [];
if (removed === "conditions")
next.conditions = [newCondition(nextId.current++)];
setApplied(next);
setDraft(next);
}
function apply() {
setApplied(draft);
setOpen(false);
}
const footer = (
<FilterFooter>
<FilterClearAll onClick={clearDraft} />
<FilterApply count={draftCount} onClick={apply} />
</FilterFooter>
);
return (
<div className="tw:flex tw:flex-col tw:items-start tw:gap-2">
<Filter
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setDraft(applied);
}
setOpen(nextOpen);
}}
>
<FilterButton>Filter</FilterButton>
<FilterContent>
<FilterHeader />
<FilterNav>
<FilterNavSaved
active={category === "Saved"}
applied={saved.length > 0}
onClick={() => setCategory("Saved")}
/>
<FilterNavItem
active={category === "Status"}
applied={draft.status !== "Any status"}
values={draft.status !== "Any status" ? [draft.status] : []}
onClick={() => setCategory("Status")}
>
Status
</FilterNavItem>
<FilterNavItem
active={category === "Region"}
applied={draft.regions.length > 0}
values={draft.regions}
onClick={() => setCategory("Region")}
>
Region
</FilterNavItem>
<FilterNavItem
active={category === "Sales and bookings"}
applied={draft.conditions.some((c) => c.action)}
values={draft.conditions
.filter((c) => c.action)
.map((c) => c.action)}
onClick={() => setCategory("Sales and bookings")}
>
Sales and bookings
</FilterNavItem>
</FilterNav>
{category === "Saved" && (
<FilterPane>
<FilterPaneHeader>
<FilterPaneBack />
<FilterPaneTitle>Saved</FilterPaneTitle>
</FilterPaneHeader>
{saved.length === 0 ? (
<p className="tw:flex-1 tw:px-4 tw:py-2 tw:text-[0.8rem] tw:text-muted-foreground">
No saved filters yet. Use Save next to your active filter
chips to add one.
</p>
) : (
<FilterOptionList>
{saved.map((filter) => (
<FilterSavedItem key={filter.id}>
<FilterSavedItemButton
onClick={() => {
setApplied(filter.selection);
setDraft(filter.selection);
setOpen(false);
}}
>
{filter.name}
</FilterSavedItemButton>
<FilterSavedItemEdit
aria-label={`Edit ${filter.name}`}
onClick={() => setEditing(filter)}
/>
<FilterSavedItemDelete
aria-label={`Delete ${filter.name}`}
onClick={() =>
setSaved(saved.filter((f) => f.id !== filter.id))
}
/>
</FilterSavedItem>
))}
</FilterOptionList>
)}
{footer}
<FilterPaneFooter onClear={clearDraft} />
</FilterPane>
)}
{category === "Status" && (
<FilterPane>
<FilterPaneHeader>
<FilterPaneBack />
<FilterPaneTitle>Status</FilterPaneTitle>
{draft.status !== "Any status" && (
<FilterPaneClear onClick={clearStatus} />
)}
</FilterPaneHeader>
<FilterOptionList>
<RadioGroup
value={draft.status}
onValueChange={(status) => setDraft({ ...draft, status })}
className="tw:gap-0.5"
>
{statuses.map((status) => (
<FilterOption key={status}>
<RadioGroupItem value={status} />
{status}
</FilterOption>
))}
</RadioGroup>
</FilterOptionList>
{footer}
<FilterPaneFooter onClear={clearStatus} />
</FilterPane>
)}
{category === "Region" && (
<FilterPane>
<FilterPaneHeader>
<FilterPaneBack />
<FilterPaneTitle>Region</FilterPaneTitle>
{draft.regions.length > 0 && (
<FilterPaneClear onClick={clearRegions} />
)}
</FilterPaneHeader>
<FilterSearch
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<FilterOptionList>
{visibleRegions.map((region) => (
<FilterOption key={region}>
<Checkbox
checked={draft.regions.includes(region)}
onCheckedChange={(checked) =>
setDraft({
...draft,
regions: checked
? [...draft.regions, region]
: draft.regions.filter((r) => r !== region),
})
}
/>
{region}
</FilterOption>
))}
</FilterOptionList>
{footer}
<FilterPaneFooter onClear={clearRegions} />
</FilterPane>
)}
{category === "Sales and bookings" && (
<FilterPane>
<FilterPaneHeader>
<FilterPaneBack />
<FilterPaneTitle>Sales and bookings</FilterPaneTitle>
{(draft.conditions.some((c) => c.action) ||
draft.conditions.length > 1) && (
<FilterPaneClear onClick={clearConditions} />
)}
</FilterPaneHeader>
<FilterPaneContent>
{draft.conditions.map((condition) => (
<Fragment key={condition.id}>
{condition.id !== draft.conditions[0].id && (
<>
<Separator />
<div className="tw:flex tw:items-center tw:gap-1.5">
<ButtonGroup className="tw:flex-1 tw:*:flex-1">
<Button
size="sm"
variant="outline"
aria-pressed={condition.operator === "and"}
className="tw:aria-pressed:bg-muted"
onClick={() =>
updateCondition(condition.id, {
operator: "and",
})
}
>
AND
</Button>
<Button
size="sm"
variant="outline"
aria-pressed={condition.operator === "or"}
className="tw:aria-pressed:bg-muted"
onClick={() =>
updateCondition(condition.id, {
operator: "or",
})
}
>
OR
</Button>
</ButtonGroup>
<Button
variant="ghost"
size="icon-sm"
aria-label="Remove condition"
className="tw:text-muted-foreground"
onClick={() =>
setDraft({
...draft,
conditions: draft.conditions.filter(
(c) => c.id !== condition.id,
),
})
}
>
<Trash2Icon />
</Button>
</div>
</>
)}
<div className="tw:flex tw:flex-col tw:gap-1.5">
<Label>Accounts that</Label>
<Select
value={condition.action}
onValueChange={(action) =>
updateCondition(condition.id, {
action: action ?? "",
})
}
>
<SelectTrigger className="tw:w-full">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
{actions.map((action) => (
<SelectItem key={action} value={action}>
{action}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</Fragment>
))}
<Separator />
<Button
variant="link"
size="sm"
className="tw:self-start tw:text-muted-foreground"
onClick={() =>
setDraft({
...draft,
conditions: [
...draft.conditions,
newCondition(nextId.current++),
],
})
}
>
<PlusIcon />
Add &quot;and / or&quot; condition
<CircleQuestionMarkIcon />
</Button>
</FilterPaneContent>
{footer}
<FilterPaneFooter onClear={clearConditions} />
</FilterPane>
)}
<FilterNavFooter>
<FilterClearAll onClick={clearDraft}>Clear filters</FilterClearAll>
<FilterApply count={draftCount} onClick={apply} />
</FilterNavFooter>
</FilterContent>
</Filter>
{countFilters(applied) > 0 && (
<>
<SelectionChips selection={applied} onRemove={removeCategory}>
<FilterSave onClick={() => setSaveOpen(true)} />
</SelectionChips>
<FilterSaveDialog
open={saveOpen}
onOpenChange={setSaveOpen}
onSave={(name) => {
setSaved([
...saved,
{ id: nextId.current++, name, selection: applied },
]);
}}
>
<SelectionChips selection={applied} />
</FilterSaveDialog>
</>
)}
<FilterSaveDialog
open={editing !== null}
onOpenChange={(isOpen) => {
if (!isOpen) setEditing(null);
}}
onSave={(name) => {
setSaved(
saved.map((f) => (f.id === editing?.id ? { ...f, name } : f)),
);
}}
title="Edit Filter"
submitLabel="Edit Filter"
defaultName={editing?.name}
>
{editing && <SelectionChips selection={editing.selection} />}
</FilterSaveDialog>
</div>
);
}

The icon-only actions — FilterChipRemove, FilterSavedItemEdit, FilterSavedItemDelete — have no accessible name of their own; give each an aria-label naming the filter it acts on.

Stateless filtering surface: a trigger button, a two-column popup (category nav plus a free-form pane), and applied-filter chips. Selection, apply, clear, and saved filter sets live in the consumer. Groups the filter’s parts without rendering an HTML element; accepts Popover props.

children?: React.ReactNode

The popover’s parts.

defaultOpen?: boolean

Whether the popover is initially open. Defaults to false.

defaultTriggerId?: string | null

ID of the trigger associated with an initially open popover.

modal?: boolean | "trap-focus"

Whether opening the popover limits interaction outside it. Defaults to false.

onOpenChange?: ((open: boolean, eventDetails: PopoverPrimitive.Root.ChangeEventDetails) => void)

Event handler called when the popover is opened or closed.

onOpenChangeComplete?: ((open: boolean) => void)

Event handler called after popover animations complete.

open?: boolean

Whether the popover is currently open.

triggerId?: string | null

ID of the trigger associated with a controlled popover.

Commits the draft selection: wire onClick to apply and close the popup. count is the number of categories with a selection and shows in the label while positive. On mobile it also returns the two-screen layout to the nav — the built-in instance inside FilterPaneFooter does only that, leaving the commit to FilterNavFooter. Renders a Button and accepts standard Button props.

className?: string

CSS class applied to the element.

count?: number = 0
focusableWhenDisabled?: boolean

Whether a disabled button stays focusable, so keyboard and screen reader users can still reach it and read its label.

nativeButton?: boolean

Whether the element passed to render is a native button. Set to false when rendering another element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

size?: "xs" | "sm" | "lg" | "default" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null = "xs"

Controls the button’s size. The icon sizes are square and are for buttons whose only child is an icon.

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns a style object based on the component’s state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null

Controls the button’s appearance only, never its behavior. The link variant looks like a link without rendering one.

Opens the filter popup. Renders a Button and accepts standard Button props.

className?: string

CSS class applied to the element.

focusableWhenDisabled?: boolean

Whether a disabled button stays focusable, so keyboard and screen reader users can still reach it and read its label.

nativeButton?: boolean

Whether the element passed to render is a native button. Set to false when rendering another element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

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

Controls the button’s size. The icon sizes are square and are for buttons whose only child is an icon.

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns a style object based on the component’s state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null = "outline"

Controls the button’s appearance only, never its behavior. The link variant looks like a link without rendering one.

With category and values, renders the canonical label — category: value for a single value, category: N options past one — before the children (the remove action). Renders a Badge and accepts standard Badge props.

category?: string
render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

values?: string[]
variant?: "outline" | "success" | "info" | "link" | "secondary" | "default" | "ghost" | "destructive" | "attention" | "highlight" | null = "secondary"

Controls the badge’s appearance only, never its behavior. The link variant looks like a link without rendering one, and status variants such as success carry no accessible semantics, so the badge’s text has to convey the meaning on its own.

Remove action for a chip. Give it an aria-label naming the filter it removes. Renders a Button and accepts standard Button props.

className?: string

CSS class applied to the element.

focusableWhenDisabled?: boolean

Whether a disabled button stays focusable, so keyboard and screen reader users can still reach it and read its label.

nativeButton?: boolean

Whether the element passed to render is a native button. Set to false when rendering another element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

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

Controls the button’s size. The icon sizes are square and are for buttons whose only child is an icon.

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns a style object based on the component’s state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null

Controls the button’s appearance only, never its behavior. The link variant looks like a link without rendering one.

Groups the applied-filter chips. Renders a <div> element and accepts standard <div> props.

Clears the whole draft selection via the consumer’s onClick. Renders a Button and accepts standard Button props.

className?: string

CSS class applied to the element.

focusableWhenDisabled?: boolean

Whether a disabled button stays focusable, so keyboard and screen reader users can still reach it and read its label.

nativeButton?: boolean

Whether the element passed to render is a native button. Set to false when rendering another element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

size?: "xs" | "sm" | "lg" | "default" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null = "xs"

Controls the button’s size. The icon sizes are square and are for buttons whose only child is an icon.

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns a style object based on the component’s state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null = "ghost"

Controls the button’s appearance only, never its behavior. The link variant looks like a link without rendering one.

Includes a portal and the positioned popup. Renders a <div> element by default and accepts standard <div> props. Below the sm breakpoint it becomes a two-screen flow: the category nav first, then the open pane in its place.

align?: Align = "start"
className?: string | ((state: PopoverPopupState) => string | undefined)
finalFocus?: boolean | React.RefObject<HTMLElement | null> | ((closeType: InteractionType) => void | boolean | HTMLElement | null)
initialFocus?: boolean | React.RefObject<HTMLElement | null> | ((openType: InteractionType) => void | boolean | HTMLElement | null)
render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PopoverPopupState>
style?: React.CSSProperties | ((state: PopoverPopupState) => React.CSSProperties | undefined)

Groups a pane’s clear and apply actions. Renders a <div> element and accepts standard <div> props.

Mobile-only bar with the popup title and a close action; put it first inside FilterContent. Renders a <div> element and accepts standard <div> props.

Lists the filter categories. Renders a <div> element and accepts standard <div> props.

Mobile-only footer for the category list. Render once alongside FilterNav, unconditionally — it hides itself once a pane is open, so it doesn’t need gating on the open category like FilterPane’s own footer. Renders a FilterFooter and accepts FilterFooter props.

Opens its category’s pane. Renders a <button> element and accepts standard <button> props.

active?: boolean

Marks the item as the currently open category.

applied?: boolean

Marks the category as having selections.

values?: string[]

The selected values, shown under the label on mobile — the single value, or a count past one.

Entry to the saved filter sets; when present, put it first inside FilterNav. Renders a FilterNavItem and accepts FilterNavItem props.

active?: boolean

Marks the item as the currently open category.

applied?: boolean

Marks the category as having selections.

values?: string[]

The selected values, shown under the label on mobile — the single value, or a count past one.

Row that toggles the Checkbox or RadioGroupItem placed inside it. Renders a <label> element and accepts standard <label> props.

Scrollable pane body for FilterOption rows; free-form controls use FilterPaneContent. Renders a <div> element and accepts standard <div> props.

Contains one category’s controls. Renders a <div> element and accepts standard <div> props.

Mobile-only back action for a pane header; returns to the category list on its own. Renders a Button and accepts standard Button props.

className?: string

CSS class applied to the element.

focusableWhenDisabled?: boolean

Whether a disabled button stays focusable, so keyboard and screen reader users can still reach it and read its label.

nativeButton?: boolean

Whether the element passed to render is a native button. Set to false when rendering another element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

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

Controls the button’s size. The icon sizes are square and are for buttons whose only child is an icon.

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns a style object based on the component’s state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null

Controls the button’s appearance only, never its behavior. The link variant looks like a link without rendering one.

Clears the open category; render it only while the category has a selection. Hidden on mobile, where the pane footer’s Clear takes over. Renders a Button and accepts standard Button props.

className?: string

CSS class applied to the element.

focusableWhenDisabled?: boolean

Whether a disabled button stays focusable, so keyboard and screen reader users can still reach it and read its label.

nativeButton?: boolean

Whether the element passed to render is a native button. Set to false when rendering another element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

size?: "xs" | "sm" | "lg" | "default" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null = "xs"

Controls the button’s size. The icon sizes are square and are for buttons whose only child is an icon.

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns a style object based on the component’s state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null = "ghost"

Controls the button’s appearance only, never its behavior. The link variant looks like a link without rendering one.

Scrollable pane body for free-form content; option lists use FilterOptionList. Renders a <div> element and accepts standard <div> props.

Mobile-only footer for a specific pane. Render it alongside a pane’s own FilterFooter — the pane hides that one on mobile while this is present. This one clears just that category via onClear, and its FilterApply returns to the category nav without applying anything; committing the whole draft happens once, in FilterNavFooter. Renders a FilterFooter and accepts FilterFooter props except children.

clearLabel?: React.ReactNode
onClear: () => void

Groups a pane’s title with its back and clear actions. Renders a <div> element and accepts standard <div> props.

Provides the pane’s title. Renders a <div> element and accepts standard <div> props.

Save action rendered next to the applied-filter chips. Renders a Button and accepts standard Button props.

className?: string

CSS class applied to the element.

focusableWhenDisabled?: boolean

Whether a disabled button stays focusable, so keyboard and screen reader users can still reach it and read its label.

nativeButton?: boolean

Whether the element passed to render is a native button. Set to false when rendering another element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

size?: "xs" | "sm" | "lg" | "default" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null = "xs"

Controls the button’s size. The icon sizes are square and are for buttons whose only child is an icon.

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns a style object based on the component’s state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null = "ghost"

Controls the button’s appearance only, never its behavior. The link variant looks like a link without rendering one.

Names a filter set. Calls onSave with the trimmed name and closes; persisting the set lives in the consumer. Children render between the name field and the footer — pass FilterChips previewing the set. Reuse the same component to edit a saved filter’s name: pass defaultName, and override title/submitLabel (e.g. both "Edit Filter"). Accepts Dialog props.

children?: React.ReactNode
defaultName?: string
defaultOpen?: boolean

Whether the dialog is initially open. To render a controlled dialog, use the open prop instead.

defaultTriggerId?: string | null

ID of the trigger associated with an initially open dialog.

description?: string = "Name this filter set to reuse it from the filter menu."
disablePointerDismissal?: boolean

Whether to prevent the dialog from closing on outside presses.

modal?: boolean | "trap-focus"

Determines if the dialog enters a modal state when open. true traps focus, locks page scroll, and disables pointer interactions outside the dialog. false allows interaction with the rest of the document. 'trap-focus' traps focus without locking scroll or disabling outside pointer interactions.

onOpenChange?: ((open: boolean, eventDetails: DialogRoot.ChangeEventDetails) => void)

Event handler called when the dialog is opened or closed.

onOpenChangeComplete?: ((open: boolean) => void)

Event handler called after animations complete when the dialog is opened or closed.

onSave: (name: string) => void
open?: boolean

Whether the dialog is currently open.

submitLabel?: string = "Save Filter"
title?: string = "Save Filter"
triggerId?: string | null

ID of the trigger associated with a controlled dialog.

Row for one saved filter set. Renders a <div> element and accepts standard <div> props.

Applies the saved set on click; the consumer owns what “apply” means. Renders a <button> element and accepts standard <button> props.

Delete action for a saved filter; give it an aria-label naming the set it deletes. Renders a Button and accepts standard Button props.

className?: string

CSS class applied to the element.

focusableWhenDisabled?: boolean

Whether a disabled button stays focusable, so keyboard and screen reader users can still reach it and read its label.

nativeButton?: boolean

Whether the element passed to render is a native button. Set to false when rendering another element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

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

Controls the button’s size. The icon sizes are square and are for buttons whose only child is an icon.

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns a style object based on the component’s state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null

Controls the button’s appearance only, never its behavior. The link variant looks like a link without rendering one.

Edit action for a saved filter; give it an aria-label naming the set it edits. Renders a Button and accepts standard Button props.

className?: string

CSS class applied to the element.

focusableWhenDisabled?: boolean

Whether a disabled button stays focusable, so keyboard and screen reader users can still reach it and read its label.

nativeButton?: boolean

Whether the element passed to render is a native button. Set to false when rendering another element.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component’s HTML element with a different tag, or compose it with another component. Accepts a React element or a function that returns the element to render.

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

Controls the button’s size. The icon sizes are square and are for buttons whose only child is an icon.

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the element, or a function that returns a style object based on the component’s state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null

Controls the button’s appearance only, never its behavior. The link variant looks like a link without rendering one.

Search field for narrowing a long option list. Renders an Input and accepts standard <input> props.