# Category picker

> A React category picker: browse a category tree with icon tiles, drill-down rows with a back button, counts and search across levels.

- 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 category-picker`
- Docs: https://wingo-ui.com/components/category-picker

## Interaction

Tap a tile or a row with subcategories and its children slide in from the right; the back button (or the breadcrumb under the title, which folds its middle four levels deep) slides you back out. Tap a leaf to pick it: it tints and a check draws in, and parents on the way to your choice carry a small dot. With selectableParents the first row of a level, “All in Electronics”, picks the whole branch. Type in the search to find a category at any depth: matches are highlighted, exact words and leaves come first (then the busier category), each row shows its path, and clearing the search (its X, Escape, the back button, or the Clear search button under “No categories found”) returns to the level you were on. The field shows the picked path in a form box and opens the picker in a popover on desktop and in a full-height bottom sheet on phones, where the sheet's title row doubles as the picker's header (the level's name and the back button appear in it as you drill in); picking a leaf closes it and returns focus to the field. Keyboard: Tab reaches the back button, the search and one item per level; in the grid the arrows move in two dimensions, in lists Up and Down move, Right opens a parent and Left goes back; Home, End, Page Up and Page Down jump; Enter or Space opens or picks; Backspace outside the search goes up; Escape clears the search, then goes up a level, and at the top closes the field's overlay. In the search, Down moves into the list and Enter picks the first match. After a drill focus lands on the first item, after going back on the parent you came from. On phones tiles sit three to a row, rows are 56px and the back button is 44px; the search only takes focus by itself on a mouse or trackpad so the keyboard never covers the tree. Under reduced motion levels cross-fade instead of sliding.

## Installation

```bash
npx wingo-ui@latest add category-picker
```

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/category-picker` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/category-picker.tsx`

npm dependencies: `npm i lucide-react motion`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Popover](https://wingo-ui.com/components/popover) ([Markdown](https://wingo-ui.com/components/popover.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.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))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.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))

## Usage

```tsx
import { Smartphone, House } from "lucide-react"
import { CategoryPicker, CategoryPickerField, type CategoryNode } from "@/components/ui/category-picker"

const categories: CategoryNode[] = [
  {
    id: "electronics",
    label: "Electronics and appliances",
    icon: <Smartphone />,
    color: "#7C3AED",
    count: 128430,
    children: [
      { id: "mobile-phones", label: "Mobile phones", count: 38420, keywords: ["iphone"] },
      { id: "computers", label: "Laptops and computers", count: 18940 },
    ],
  },
  { id: "real-estate", label: "Real estate", icon: <House />, color: "#16A34A", count: 98215 },
]

export function PickCategory() {
  return (
    <div className="flex flex-col gap-6">
      <CategoryPicker
        categories={categories}
        onValueChange={(id, path) => console.log(id, path.map((n) => n.label).join(" › "))}
        labels={{ search: "Find a category", allCategories: "Everything" }}
      />
      <CategoryPickerField
        categories={categories}
        label="Category"
        placeholder="Pick a category"
        required
        name="category"
      />
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `categories` | `CategoryNode[]` |  | The tree. CategoryNode = { id; label; icon?; image?; color?; count?; description?; keywords?; href?; disabled?; children? }. A node with children drills, one without (or with children: []) is picked. color tints the icon tile and is inherited by the children; keywords feed the search; href turns a leaf into a link (browse mode). |
| `value?` | `string` |  | The picked category id, controlled. A value that is not in the tree shows nothing selected and never crashes. |
| `defaultValue?` | `string` | `""` | The starting value while uncontrolled. The picker opens on the level that holds it, so a reopened field shows the current choice in context. |
| `onValueChange?` | `(id: string, path: CategoryNode[]) => void` |  | Fires when another category is picked, with the path from the top level down to it. |
| `onPick?` | `(id: string, path: CategoryNode[]) => void` |  | Fires on every pick, including the current value again. Overlays close on it (the field does). |
| `variant?` | `"grid" \| "list"` | `"grid"` | grid: icon tiles at the top level and rows below it (a marketplace home). list: rows everywhere, for sheets, sidebars and filter panels. |
| `columns?` | `number` | `0` | Tile columns. 0 fits the container: 3 in a phone-width box, 4 from 448px, 6 from 672px (container widths, so it also works in a sidebar or a popover). |
| `selectableParents?` | `boolean` | `false` | A parent can be picked through an “All in …” row at the top of its level (search a whole branch). Otherwise parents only drill. |
| `search?` | `boolean` | `true` | The search box over every level. Enter picks the first match; it never submits a surrounding form. |
| `showCounts?` | `boolean` | `true` | Listing counts on tiles, rows and the level header, formatted for the locale (128.430). Screen readers hear them with labels.count. |
| `iconStyle?` | `"tile" \| "plain"` | `"tile"` | tile puts the icon in a rounded square tinted with the node's color (neutral gray without one); plain shows the colored icon alone. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Tiles 88 / 104 / 120px tall with 40 / 48 / 56px icon tiles; rows 44 / 56 / 64px; the search box follows. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The selected tile or row: a gray tint for neutral, the soft tone otherwise, plus the check and the dot on parents that hold the choice. |
| `color?` | `string` | `""` | Any CSS color for the selected look; replaces the tone. Category colors come from the nodes, not from this. |
| `locale?` | `string` | `""` | Formats the counts and the results line. Empty uses the UIProvider's locale, then the browser's. |
| `query?` | `string` |  | The search text, controlled. Pair it with onQueryChange. |
| `defaultQuery?` | `string` | `""` | The search text on the first render, e.g. a term carried over from the site search. |
| `onQueryChange?` | `(query: string) => void` |  | Fires as the search text changes. |
| `linkComponent?` | `(props: LinkComponentProps) => ReactNode` |  | Renders leaves that have an href (browse mode) instead of picking them. Defaults to the LinkProvider's component (Next's Link in an app), then a plain <a>. |
| `labels?` | `Partial<CategoryPickerLabels>` |  | Every string, English by default (DEFAULT_CATEGORY_PICKER_LABELS): search "Search categories", back "Back", allCategories "All categories", all (label) => "All in …", empty "No categories found", results (n, formatted) => "12 categories", count (n, formatted) => "128,430 listings", separator "›", subcategories "has subcategories", containsSelected "contains your choice", clear "Clear search", breadcrumb "Category path". |
| `classNames?` | `Partial<Record<"top" \| "header" \| "back" \| "title" \| "breadcrumb" \| "search" \| "panel" \| "grid" \| "list" \| "item" \| "icon" \| "label" \| "count" \| "results" \| "empty", string>>` |  | Extra classes for the inner parts. top wraps the header, breadcrumb and search (the field pins it with sticky). |

## Parts

### CategoryPickerField (component)

The form field: the W2 field anatomy (label, description, error, required, size, variant soft | outline, radius, name for a hidden input) around a box that shows the picked path with the leaf's icon, placeholder "Choose a category". Opens a CategoryPicker variant="list" in a popover on desktop and a full-height bottom sheet on phones (responsive, default true; sheetTitle defaults to the label's text, then "Category", and heads the top level inside the sheet). open / defaultOpen / onOpenChange, container, plus the picker's selectableParents, search, showCounts, iconStyle, tone, color, locale, labels and linkComponent; classNames (field parts, trigger, value, chevron, content), pickerClassNames and fieldProps reach the inner parts. Picking a leaf closes it and returns focus to the field; a form reset restores defaultValue. Props: CategoryPickerFieldProps.

### findCategoryPath (function)

The path from the top level down to the node with this id (the node last), or [] when it is not in the tree. Breadcrumbs, readouts and filters use it. Signature: (categories: CategoryNode[], id: string) => CategoryNode[].

### flattenCategories (function)

Every node in reading order with its path; the search runs over it, and a plain select of categories can too. Signature: (categories: CategoryNode[]) => { node: CategoryNode; path: CategoryNode[] }[].

### DEFAULT_CATEGORY_PICKER_LABELS (constant)

The English strings. Spread and override for another language. Type: CategoryPickerLabels.

## Examples

- **Post an ad**: The field with required and an error after submit: a popover on desktop, a full-height sheet on phones.
- **Sidebar with parents**: List rows everywhere; selectableParents adds an “All in …” row to pick a whole branch.
- **Search every level**: “phone” finds matches at any depth, leaves first, each with its path.
- **Browse links**: Nodes with href render through the LinkProvider's link; here a pretend router.
- **Compact grid**: Plain icons, size sm and four fixed columns.
- **Four levels deep**: Cars › Car parts › Engine › Filters: the breadcrumb folds its middle.
- **Photo tiles**: Nodes with an image instead of an icon, size lg.
- **Brand field**: Outline, large, tone brand, parents selectable.

## See also

- [Filter panel](https://wingo-ui.com/components/filter-panel) ([Markdown](https://wingo-ui.com/components/filter-panel.md))
- [Listing grid](https://wingo-ui.com/components/listing-grid) ([Markdown](https://wingo-ui.com/components/listing-grid.md))
- [Tree View](https://wingo-ui.com/components/tree-view) ([Markdown](https://wingo-ui.com/components/tree-view.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Popover](https://wingo-ui.com/components/popover) ([Markdown](https://wingo-ui.com/components/popover.md))

---

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