Filter

The faceted filter control above a table or list — a trigger carrying the applied count, and a popover of checklists grouped by facet with a per-option tally, multi-select inside and across facets, and a clear action. Exported as FilterMenu, with the panel, trigger, group and row available separately so the same checklist can be mounted somewhere else. The applied-count badge takes --selected rather than the count badge's --info on purpose: a tally that says “this control is switched on” is selection, not information.

tsx
import {
  FilterMenu, FilterMenuPanel, FilterMenuTrigger, countFilterSelection,
} from "@pestoui/core"

Default

Uncontrolled and live: every checkbox lands immediately, and the badge on the trigger counts the values across every facet. The footer mounts with the first applied filter and carries Clear all — which hands focus to the search box or the first checkbox on its way out, because the button unmounts the instant it succeeds. Each facet is a role="group" labelled by its heading, which is what lets two facets that happen to share an option label still be told apart — the rows themselves carry no visible qualifier.

Searchable

searchable adds a box above the list that narrows options by label and drops any facet left with nothing. When the query matches nothing at all the body falls back to an EmptyState — and emptyTitle / emptyDescription override the copy for both that case and the one where there is genuinely nothing to filter on.

Deferred with Apply

Passing onApply renders the Apply button and switches the panel to a draft: the badge then counts the last committed set rather than what is currently checked. Three consequences worth knowing — closing without applying keeps the draft, so reopening resumes where you left off, but the badge does not move because nothing was committed; Clear all edits the draft like any other control, so it still needs an Apply to take effect; and onValueChange keeps firing on every toggle, which is the draft, not the commit.

Applied: status: active

Trigger

label renames the button, variant and size take it down to a toolbar control, and disabled refuses the popover outright. The chevron is one glyph that turns a half turn, so the resting shape is identical in both states and only the change between them is animated.

The panel on its own

FilterMenuPanel is the body and footer with no popover chrome, so the same checklist can be laid flat in a sidebar or mounted in a Sheet on a narrow viewport. surface="flush" makes it paint its own card; the default assumes PopoverContent has already painted one. It is fully controlled by design — there is deliberately no prop that swaps its own container, because two mount points sharing one draft is a state-duplication trap. countFilterSelection is exported so a consumer tallies the applied set exactly the way the badge does.

Status
Channel
Owner

0 applied

Trigger and panel mounted by hand

FilterMenu is the trigger and the panel wired into a Popover for you. Both halves are exported, so a surface that is not a Popover — a Sheet on a narrow viewport, a panel docked into a page — can mount FilterMenuPanel itself and still use FilterMenuTrigger for the button, keeping the applied-count badge and the chevron identical to every other filter in the product. count is passed in rather than derived, because a hand-mounted panel owns the selection; pass countFilterSelection(selection) and the tally matches what FilterMenu would have shown. The badge takes the selected accent, not the badge's own count colours — an applied tally is selection semantics, not information.

Props

PropTypeDefaultDescription
facetsFilterFacet[]—The checklists, in render order. Each is { id, label, options } and the id is what the selection is keyed by.
facets[].optionsFilterOption[]—{ value, label, count?, disabled? }. count is the right-aligned tally; omit it to hide the column for that row.
valueFilterSelection—Controlled draft — Record<facetId, string[]>. A facet with nothing checked is absent rather than present with an empty array, so “is anything applied” is a key count.
defaultValueFilterSelection{}Initial selection when the control is uncontrolled.
onValueChange(next: FilterSelection) => void—Fires on every toggle and on Clear all. In deferred mode this is the draft, not the commit.
onApply(selection: FilterSelection) => void—Presence of this handler renders Apply and switches the panel to a draft. Omit it and every toggle is live.
labelReact.ReactNode"Filters"Text on the trigger, before the applied-count badge.
variant"outline" | "secondary" | "ghost""outline"Trigger style.
size"sm" | "default""default"Trigger height.
disabledbooleanfalseDims the trigger and refuses to open the popover.
searchablebooleanfalseAdds the search box above the list. It filters on the option label and drops any facet left with no matches.
searchPlaceholderstring"Search filters"Placeholder text, and the search box's accessible name.
openboolean—Controls the popover. Leave it off for the uncontrolled default.
onOpenChange(open: boolean) => void—Fires whenever the popover wants to open or close, controlled or not.
align"start" | "center" | "end""start"Popover alignment against the trigger. The panel is width-driven by its content with a floor, and kept off the viewport edge so it never flips sides mid-interaction.
sideOffsetnumber6Gap between trigger and panel.
clearLabelReact.ReactNode"Clear all"Text on the footer's clear button, which appears only once something is selected.
applyLabelReact.ReactNode"Apply"Text on the commit button. Rendered only when onApply is present.
emptyTitleReact.ReactNode—Overrides the empty-state heading, used both when a search matches nothing and when there are no facets at all.
emptyDescriptionReact.ReactNode—Overrides the empty-state body line.
panelClassNamestring—Classes for the PopoverContent that holds the panel — width, max height, anything about the container rather than the list.
FilterMenuPanel surface"popover" | "flush""popover"popover adds nothing, since PopoverContent already paints fill, ring and shadow. flush paints its own card, for a panel laid out in a page or inside a Sheet.
FilterMenuTrigger countnumber0Applied tally on the standalone trigger. Zero hides the badge entirely. FilterMenu passes this for you; a hand-mounted panel owns its selection, so pass countFilterSelection(selection) to match.
FilterMenuTrigger labelReact.ReactNode"Filters"Text between the icon and the badge. Everything else on the trigger is Button's own props.

Pesto UI · 117 documented components