Category picker React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React category picker: browse a category tree with icon tiles, drill-down rows with a back button, counts and search across levels.
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.
Category picker is a Pro React component in the React commerce components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add category-picker, together with Button, Field, Input, Popover, useControllableState and 8 more and the npm packages lucide-react and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add category-pickerCategory picker is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import CategoryPicker from @/components/ui/category-picker.
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
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).
valuestringThe picked category id, controlled. A value that is not in the tree shows nothing selected and never crashes.
defaultValuedefault""stringThe 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[]) => voidFires when another category is picked, with the path from the top level down to it.
onPick(id: string, path: CategoryNode[]) => voidFires on every pick, including the current value again. Overlays close on it (the field does).
variantdefault"grid""grid" | "list"grid: icon tiles at the top level and rows below it (a marketplace home). list: rows everywhere, for sheets, sidebars and filter panels.
columnsdefault0numberTile 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).
selectableParentsdefaultfalsebooleanA parent can be picked through an “All in …” row at the top of its level (search a whole branch). Otherwise parents only drill.
searchdefaulttruebooleanThe search box over every level. Enter picks the first match; it never submits a surrounding form.
showCountsdefaulttruebooleanListing counts on tiles, rows and the level header, formatted for the locale (128.430). Screen readers hear them with labels.count.
iconStyledefault"tile""tile" | "plain"tile puts the icon in a rounded square tinted with the node's color (neutral gray without one); plain shows the colored icon alone.
sizedefault"md""sm" | "md" | "lg"Tiles 88 / 104 / 120px tall with 40 / 48 / 56px icon tiles; rows 44 / 56 / 64px; the search box follows.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"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.
colordefault""stringAny CSS color for the selected look; replaces the tone. Category colors come from the nodes, not from this.
localedefault""stringFormats the counts and the results line. Empty uses the UIProvider's locale, then the browser's.
querystringThe search text, controlled. Pair it with onQueryChange.
defaultQuerydefault""stringThe search text on the first render, e.g. a term carried over from the site search.
onQueryChange(query: string) => voidFires as the search text changes.
linkComponent(props: LinkComponentProps) => ReactNodeRenders 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>.
labelsPartial<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".
classNamesPartial<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
componentThe 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
functionThe 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
functionEvery 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
constantThe 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.
Dependencies
Files
- components/ui/category-picker.tsx
FAQ
Is the React Category picker component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Category picker?
Run npx wingo-ui@latest add category-picker. It also adds the Wingo UI items it builds on (Button, Field, Input, Popover, useControllableState and 8 more) and the npm packages lucide-react and motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/category-picker.
How does Category picker behave on phones?
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.