# Filter panel

> A React filter panel for faceted search: a sidebar that applies as you go on desktop, a full-height sheet with a live count on phones.

- Type: React component (Commerce)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add filter-panel`
- Docs: https://wingo-ui.com/components/filter-panel

## Interaction

On a wide screen the filters sit in a sidebar and every change applies at once: tap a section title to fold or unfold it (a dot marks the ones in use, and a folded section shows its choice under the title, like “Apple, Samsung”), drag the price thumbs over the histogram and let go, type a price and press Enter, pick a category row, tick a brand, tap a condition or radius chip, flip a switch. Tap “Show 7 more” for the rest of a long list, or type in its search field when it has more than ten options. The chips under the title remove one value each with their × (or Backspace and Delete while focused), and “Clear all” in the header clears every filter. With applyMode set to apply the sidebar keeps a draft until you press Apply. Below the breakpoint a Filters pill with the active count takes the sidebar’s place: tap it and a full-height sheet slides up (a side panel on desktop in sheet mode) with the same sections, the first two open, and a footer button that counts your draft as you choose (“Show 1.240 results”, a spinner while it counts, “No results, try fewer filters” when nothing matches). The button applies and closes; swiping the sheet down, tapping the backdrop or pressing Escape throws the draft away, and Reset clears only the draft. The place field opens its own searchable sheet on a phone, and “My location” fills it in when the page can locate you. Keyboard: Tab walks the sections, Enter or Space folds them and arrows move between their titles; every control keeps its own keys (arrows and Page keys on the sliders, Enter in the inputs, arrows in the chip and radio groups). A shortcut prop (like mod+shift+f) opens the sheet, or jumps to the first section while the sidebar shows. Under reduced motion sections, chips and the count fade instead of moving.

## Installation

```bash
npx wingo-ui@latest add filter-panel
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/filter-panel` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/filter-panel.tsx`

npm dependencies: `npm i lucide-react motion`

Wingo UI dependencies (installed automatically):

- [Accordion](https://wingo-ui.com/components/accordion) ([Markdown](https://wingo-ui.com/components/accordion.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Category picker](https://wingo-ui.com/components/category-picker) ([Markdown](https://wingo-ui.com/components/category-picker.md))
- [Checkbox](https://wingo-ui.com/components/checkbox) ([Markdown](https://wingo-ui.com/components/checkbox.md))
- [Combobox](https://wingo-ui.com/components/combobox) ([Markdown](https://wingo-ui.com/components/combobox.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Kbd](https://wingo-ui.com/components/kbd) ([Markdown](https://wingo-ui.com/components/kbd.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Number Input](https://wingo-ui.com/components/number-input) ([Markdown](https://wingo-ui.com/components/number-input.md))
- [Price range](https://wingo-ui.com/components/price-range) ([Markdown](https://wingo-ui.com/components/price-range.md))
- [Radio Group](https://wingo-ui.com/components/radio-group) ([Markdown](https://wingo-ui.com/components/radio-group.md))
- [Slider](https://wingo-ui.com/components/slider) ([Markdown](https://wingo-ui.com/components/slider.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [Switch](https://wingo-ui.com/components/switch) ([Markdown](https://wingo-ui.com/components/switch.md))
- [Tag](https://wingo-ui.com/components/tag) ([Markdown](https://wingo-ui.com/components/tag.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useDebounce](https://wingo-ui.com/components/use-debounce) ([Markdown](https://wingo-ui.com/components/use-debounce.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))

## Usage

```tsx
import { useState } from "react"
import { Truck } from "lucide-react"
import { FilterPanel, type FilterDef, type FilterValues } from "@/components/ui/filter-panel"

const filters: FilterDef[] = [
  { type: "price", id: "price", label: "Price", min: 0, max: 1800, presets: [{ min: null, max: 100 }, { min: 100, max: 300 }] },
  {
    type: "chips",
    id: "condition",
    label: "Condition",
    options: [
      { value: "new", label: "New", count: 310 },
      { value: "used", label: "Used", count: 843 },
      { value: "parts", label: "For parts", count: 87 },
    ],
  },
  {
    type: "checkbox",
    id: "brand",
    label: "Brand",
    options: [
      { value: "apple", label: "Apple", count: 312 },
      { value: "samsung", label: "Samsung", count: 287 },
      { value: "xiaomi", label: "Xiaomi", count: 164 },
    ],
  },
  { type: "toggle", id: "delivery", label: "Delivery only", icon: <Truck /> },
]

export function PhoneFilters({ count }: { count: (values: FilterValues) => Promise<number> }) {
  const [values, setValues] = useState<FilterValues>({})
  return (
    <div className="flex flex-col gap-4 lg:flex-row lg:items-start">
      <FilterPanel
        filters={filters}
        value={values}
        onValueChange={setValues}
        getResultCount={count}
        locale="en-US"
        className="lg:w-72 lg:shrink-0"
      />
      <main className="min-w-0 flex-1">{/* results */}</main>
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `filters` | `FilterDef[]` |  | The sections, in order. A union on type: category (a CategoryPicker list), price (a PriceRange over a histogram), location (a place combobox, popular places and radius chips; "My location" with onLocate), checkbox (rows with counts, a limit and "Show more", a search field past ten options), radio (with an "any" row), chips (single or multiple), range (a two-thumb slider and two number inputs with a unit), toggle (a switch row) and custom (render, describe, isActive). Every filter has an id, a label, an optional description and defaultCollapsed. Unknown types are skipped with a warning in development. |
| `value?` | `FilterValues` |  | The committed filters, keyed by filter id: category and radio a string, price and range [min, max] with null for an open end, location { place, radius }, checkbox and chips string[], toggle true. Inactive filters leave the object; keys of filters not in the list are kept. |
| `defaultValue?` | `FilterValues` |  | The starting filters while uncontrolled. Defaults to {}. |
| `onValueChange?` | `(values: FilterValues) => void` |  | The sidebar in instant mode calls it on every commit (a slider release, a toggle, a pick, never per keystroke); the sheet and apply mode call it on the Apply button; chips and Clear all call it at once in the sidebar. |
| `mode?` | `"auto" \| "sidebar" \| "sheet"` | `"auto"` | auto shows the sidebar from the breakpoint up and the Filters button with a bottom sheet below it (switched with CSS, so the server HTML is right). sidebar always shows the sidebar; sheet always shows the button, with a side panel on desktop. |
| `breakpoint?` | `"md" \| "lg"` | `"lg"` | Where auto switches from the sheet to the sidebar: 768px or 1024px. Crossing it with the sheet open closes the sheet. |
| `applyMode?` | `"instant" \| "apply"` | `"instant"` | The sidebar's behavior: instant commits each change, apply keeps a draft until the Apply button in its footer. The sheet always keeps a draft. |
| `getResultCount?` | `(values: FilterValues) => (number \| Promise<number>)` |  | Counts the sheet's draft for its button, 250ms after the last change (the first count of an opening runs at once). A promise shows a spinner; an older answer never overwrites a newer one; a rejection falls back to the plain Show results label. |
| `resultCount?` | `number` | `0` | A fixed count for the sheet's button when there is no getResultCount. 0 reads Show results. |
| `open?` | `boolean` |  | The sheet's open state, controlled. Opening copies the committed value into a draft; a value changing while it is open does not touch the draft. |
| `defaultOpen?` | `boolean` | `false` | Starts with the sheet open (only where the sheet is in use: sheet mode, or auto below the breakpoint). |
| `onOpenChange?` | `(open: boolean) => void` |  | Called when the sheet opens or closes, by the button, a shortcut, a swipe, the backdrop or Escape. |
| `trigger?` | `ReactNode` | `"<FilterPanelTrigger />"` | Replaces the Filters button that opens the sheet (a single element that takes a ref). null renders none, so you can open the sheet from a toolbar with open and onOpenChange. |
| `showChips?` | `boolean` | `true` | The active filters as removable chips under the title (sidebar) or at the top of the sheet. |
| `collapsible?` | `boolean` | `true` | Sections fold like an accordion. false keeps every section open under a plain heading. |
| `defaultExpanded?` | `string[]` |  | Filter ids open at first. Defaults to the first four sections in the sidebar and the first two in the sheet (skipping toggles and those with defaultCollapsed). |
| `hideEmpty?` | `boolean` | `false` | Hides options with a count of 0 instead of showing them disabled. A picked option always stays. |
| `sticky?` | `boolean` | `true` | The sidebar sticks under var(--topbar-offset, 0px) and scrolls on its own when it is taller than the screen. |
| `size?` | `"sm" \| "md"` | `"md"` | The controls inside: checkboxes, switches, sliders, fields and chips. sm suits a narrow sidebar. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Checked boxes, picked chips, the slider ranges, the lit histogram bars, the section dots, the count badge and the Apply button. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone everywhere it paints. |
| `shortcut?` | `string` | `""` | A key combo in mod+k notation that opens (and closes) the sheet, or jumps to the first section while the sidebar shows. The button shows it as a key hint on devices with a keyboard. None by default. |
| `currency?` | `string` | `"USD"` | ISO 4217 code for the price section, its chips and summaries. |
| `locale?` | `string` | `""` | Counts, prices and ranges (1,240 in en-US). Empty uses the UIProvider's locale, then the browser's. |
| `container?` | `HTMLElement \| null` |  | Portal target of the sheet. Defaults to document.body. |
| `labels?` | `Partial<FilterPanelLabels>` |  | Every string, English by default (DEFAULT_FILTER_PANEL_LABELS): title "Filters", clearAll "Clear all", reset "Reset", apply "Apply", showResults (count, formatted), showResultsUnknown "Show results", noResults "No results, try fewer filters", any "Any", anywhere "Anywhere", radius (km) "+25 km" ("Only here" for 0), distance, myLocation "My location", showMore (count), showLess, searchOptions, noMatches, from, to, rangeFrom (value), rangeTo (value), trigger "Filters", active (count) "3 active", chips, remove, clear, cancel (the place field and its phone sheet), plus priceRange and categoryPicker labels passed through. |
| `classNames?` | `Partial<Record<"sidebar" \| "header" \| "chips" \| "section" \| "trigger" \| "content" \| "footer" \| "apply", string>>` |  | Extra classes for the parts: the sidebar's aside (give it a panel look here), the headers, the chip row, each section, the Filters button, the sheet, the footers and the Apply buttons. |
| `className?` | `string` |  | Extra classes for the root; the page sets the sidebar's width here (lg:w-72). |

## Parts

### FilterPanelTrigger (component)

The Filters pill on its own: an outline button with the sliders icon and a count badge that pops when it changes; its accessible name reads "Filters, 3 active" and a shortcut goes to aria-keyshortcuts (its key hint is hidden from screen readers and on touch). tone and color paint the badge. Props: ButtonProps & { count?: number; label?: ReactNode; activeLabel?: (count: number) => string; shortcut?: string; locale?: string }.

### FilterChips (component)

The active filters as removable chips for a toolbar or over the results, with Clear all once there are two. Renders nothing while no filter is active. Props: { filters; value?; defaultValue?; onValueChange?; showClearAll?; size?; tone?; color?; currency?; locale?; labels?; classNames? }.

### describeFilters (function)

The chips as data, also the shape of toolbar's appliedFilters: one per checkbox or chip value ("Apple"), one per other filter ("Price: $500 to $3,000", "Chicago +25 km"). Signature: (filters: FilterDef[], values: FilterValues, options?: { locale?; currency?; labels? }) => { id: string; filterId: string; label: ReactNode }[].

### removeFilterValue (function)

The values without one chip: a single checkbox value, or the whole filter. Signature: (filters: FilterDef[], values: FilterValues, chipId: string) => FilterValues.

### countActiveFilters (function)

How many filters are in use (two brands count once), for a badge or a heading. Signature: (filters: FilterDef[], values: FilterValues) => number.

### FilterDef (type)

One filter, by type: category, price, location, checkbox, radio, chips, range, toggle or custom, each with an id and a label. The option lists take FilterOption: { value, label, count?, icon?, description?, disabled?, keywords?, textValue? }; a count of 0 disables the option.

### DEFAULT_FILTER_PANEL_LABELS (constant)

Every English string: title ("Filters"), Clear all, Reset, Apply, showResults ("Show 1,284 results"), the Any / Anywhere options, distance and radius, show more and less, the range captions, the trigger and chip row labels. Spread it into labels to translate a few. Type: FilterPanelLabels.

### FilterValues (type)

The panel's value: a record keyed by filter id. Prices and ranges hold FilterRangeValue ([min, max], null for an open end), locations hold FilterLocationValue ({ place, radius }).

## Examples

- **Sheet on a phone**: mode="sheet" with a getResultCount that takes 400ms: the button spins, then fades to the new count.
- **Sidebar with an Apply button**: applyMode="apply" keeps a draft until Apply filters; chips still remove at once.
- **Chips under a toolbar**: A toolbar holds the sheet's Filters button and the sort; FilterChips below it: one chip per value, Clear all once there are two.
- **Real estate**: A log price track, bedroom chips, area and year ranges, a floor radio and an owner toggle.
- **Cars, size sm**: Forty brands behind a search field, year and mileage ranges, fuel chips.
- **Zero results**: The sheet's button reads No matches and stays pressable.

## See also

- [Toolbar](https://wingo-ui.com/components/toolbar) ([Markdown](https://wingo-ui.com/components/toolbar.md))
- [Price range](https://wingo-ui.com/components/price-range) ([Markdown](https://wingo-ui.com/components/price-range.md))
- [Category picker](https://wingo-ui.com/components/category-picker) ([Markdown](https://wingo-ui.com/components/category-picker.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Accordion](https://wingo-ui.com/components/accordion) ([Markdown](https://wingo-ui.com/components/accordion.md))

---

Canonical: https://wingo-ui.com/components/filter-panel
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
