Tree Select React component for Tailwind CSS v4
v1.0.0Added in 1.14.0
A React tree select: a field that opens a searchable tree of categories, folders or locations, for one value or many as removable tags.
Click or tap the field and a tree opens under it with the search row focused. Type to filter: matches keep their parents, the path to them opens and the matched letters turn bold. Click a row to pick it and the list closes; clicking a folder only opens it unless selectableFolders is on (then the chevron opens it). With multiple, each row has a checkbox and the list stays open: checking a folder checks its whole branch, a folder turns to a dash when only some of its children are checked, the trigger shows removable tags up to maxTagCount and then +N, and the footer counts and clears. Keyboard: on the closed field Enter, Space, ArrowDown or ArrowUp open it and a letter opens it with that letter in the search; Backspace removes the last tag (or clears a single value with clearable). In the search ArrowDown or Enter move into the tree. In the tree ArrowDown and ArrowUp move (ArrowUp on the first row returns to the search), ArrowRight opens a folder or steps into it, ArrowLeft closes it or jumps to the parent, Home and End jump, typing letters jumps to the next row that starts with them, Enter picks a row (or opens a folder), Space picks or checks, Escape closes and puts focus back on the field, and Tab moves on from the field. On a phone the list opens as a bottom sheet with the search pinned under the title and 44px rows; with multiple a Done button closes it.
Tree Select is a Pro React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add tree-select, together with Tree View, Field, Drawer, Tag, Button and 5 more and the npm packages @radix-ui/react-popover, motion and lucide-react.
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 tree-selectTree Select 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 TreeSelect from @/components/ui/tree-select.
import { useState } from "react"
import TreeSelect, { type TreeSelectNode } from "@/components/ui/tree-select"
const categories: TreeSelectNode[] = [
{
id: "electronics",
label: "Electronics",
children: [
{ id: "phones", label: "Phones", children: [{ id: "iphone", label: "iPhone" }, { id: "android", label: "Android" }] },
{ id: "laptops", label: "Laptops" },
],
},
{ id: "home", label: "Home & Kitchen", children: [{ id: "furniture", label: "Furniture" }] },
]
export function ProductCategory() {
const [category, setCategory] = useState("android")
const [filters, setFilters] = useState<string[]>(["phones"])
return (
<div className="grid gap-4">
<TreeSelect label="Category" data={categories} value={category} onValueChange={setCategory} showPath />
<TreeSelect label="Show products in" data={categories} multiple value={filters} onValueChange={setFilters} clearable />
</div>
)
}Props
data*TreeSelectNode[]The tree: { id, label, textValue?, icon?, description?, disabled?, children? }. Ids must be unique across the whole tree; a label that is not a string needs textValue for the search, the tags and the path.
multipledefaultfalsebooleanSeveral values as removable tags, with checkboxes in the tree. The value becomes a string[].
valuestring | string[]The chosen id ("" is nothing), or ids with multiple (controlled). Pair it with onValueChange.
defaultValuedefault""string | string[]The starting value while uncontrolled ([] with multiple). A form reset returns to it.
onValueChange(value: string) => void | (value: string[]) => voidCalled with the new id, or the new list with multiple.
placeholderdefault"Select an option"stringShown while nothing is chosen. Also titles the phone sheet when there is no label.
labeldefault""ReactNodeThe label above the field; plain text also names the tree and titles the phone sheet.
descriptiondefault""ReactNodeHelper text under the field, linked with aria-describedby.
errordefault""ReactNodeReplaces the description with an error and marks the field invalid.
requireddefaultfalsebooleanAdds the asterisk and aria-required; with name, an empty field blocks the form submit.
searchabledefaulttruebooleanA search row on top of the tree. Matches keep their parents, the path to them opens and the matched letters turn bold; accents never matter.
selectableFoldersdefaultfalsebooleanFolders can be picked too (single), or checked on their own (multiple without cascade). Off: a folder row only opens and closes.
cascadedefaulttruebooleanMultiple only: checking a folder checks its branch and a fully checked branch checks its folder. Off: every row checks on its own.
checkStrategydefault"parent""all" | "parent" | "child"Multiple with cascade: what the value holds. parent keeps the topmost fully checked folder instead of its children, child keeps only the leaves, all keeps every checked id.
defaultExpandeddefaultfalseboolean | string[]Folders open on first render: true opens every folder, or pass ids. The path to the chosen value always opens with the list.
showPathdefaultfalsebooleanThe field shows the whole path (Electronics / Phones / Android). The folders truncate first so the picked name stays readable; tags keep the path in their title.
pathSeparatordefault" / "stringBetween the names of the path.
clearabledefaultfalsebooleanAn x in the field that clears the value. It only shows while something is chosen.
maxTagCountdefault2numberMultiple only: tags shown before a +N tag (its title lists the rest). 0 shows every tag.
wrapTagsdefaultfalsebooleanMultiple only: tags wrap onto more lines and the field grows, instead of one clipped row.
guidesdefaulttruebooleanVertical indent guides in the tree.
folderIconsdefaultfalsebooleanFolder and file icons for nodes that have no icon of their own.
sizedefault"md""sm" | "md" | "lg"Field height 32 / 40 / 48px (44px on touch for md), the tree rows and the tags follow.
variantdefault"soft""soft" | "outline" | "underline" | "ghost"The field look, the same as every input and select.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner shape, scaled with the size like the button (md is 8 / 10 / 12px). Tags follow it.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The checkboxes, the picked row, the tags and the sheet's Done button.
colordefault""stringAny CSS color; replaces the tone. Leave empty to use the tone.
invaliddefaultfalsebooleanThe red ring and aria-invalid without a message (error sets it too).
disableddefaultfalsebooleanDims the field and blocks it.
readOnlydefaultfalsebooleanShows the value and keeps it focusable, but the list does not open and tags cannot be removed.
responsivedefaulttruebooleanA bottom sheet below 768px, with the search pinned under the title, 44px rows and (multiple) a Done button.
sheetTitledefault""stringThe phone sheet's title. Default: the label's text, then aria-label, then the placeholder.
sidedefault"bottom""top" | "bottom"Where the popover opens; it flips when there is no room.
aligndefault"start""start" | "center" | "end"Popover alignment against the field.
maxHeightdefault360numberThe popover's maximum height in px (at least 160); it never grows past the room on screen.
namedefault""stringA hidden input per value with this name, so the field submits with a form (FormData.getAll(name)).
idstringThe trigger's id; the label points to it.
leftIconReactNodeAn icon at the start of the field.
openbooleanControlled open state. Pair it with onOpenChange.
defaultOpendefaultfalsebooleanStarts with the list (or the sheet) open. Pass open and onOpenChange to control it.
onOpenChange(open: boolean) => voidCalled when the list opens or closes.
labelsPartial<TreeSelectLabels>Every string: { search: "Search", clearSearch: "Clear search", noResults: "No matches", clear: "Clear", done: "Done", remove: "Remove", more: (n) => "+n", selected: (n) => "n selected", tree: "Options" }.
containerHTMLElement | nullPortal target for the popover and the sheet (default document.body).
fieldPropsPartial<FieldProps>Anything else for the Field wrapper: optional, labelAction, orientation, hideLabel.
classNamesPartial<Record<"field" | "label" | "description" | "error" | "box" | "trigger" | "value" | "path" | "placeholder" | "tags" | "tag" | "overflow" | "clear" | "chevron" | "content" | "search" | "tree" | "footer", string>>Extra classes for the inner parts.
classNamestringMerged onto the Field root when there is a label, description or error, otherwise onto the box.
refRefObject<HTMLButtonElement | null> or (node) => voidThe trigger button; other button props (onBlur, aria-*, data-*, form) land there too.
Parts
TreeSelectNode
typeOne node of data: { id, label, textValue?, icon?, description?, disabled?, children? }.
TreeSelectProps
typeThe props, with value typed as a string or, with multiple, a string[].
TreeSelectLabels
typeThe shape of the labels prop.
DEFAULT_TREE_SELECT_LABELS
constantThe English labels; spread them to translate a few strings.
getTreeSelectPath
functionThe names from the root down to a node: getTreeSelectPath(data, "android") gives ["Electronics", "Phones", "Android"]. Handy for a summary or a breadcrumb.
data*TreeSelectNode[]The tree.
id*stringThe node; an unknown id gives [].
treeSelectText
functionA node's plain text: textValue, else a string label, else the id.
Examples
With the path
showPath shows Electronics / Laptops / Windows laptops and clearable adds an x.
Several categories
multiple turns rows into checkboxes and values into tags; a folder checks its whole branch.
Check strategies
The same picks reported three ways: every id, the topmost folders, or the leaves only.
Pick a department or a team
selectableFolders lets a folder be the value; the chevron opens it.
Wrapping tags
checkStrategy="child" keeps cities only, maxTagCount={0} and wrapTags show every tag.
Independent checks
cascade={false} with selectableFolders: each folder is its own choice, in the info tone.
Sizes and states
sm, md and lg line up with every field and button; disabled and invalid below.
In a form
required, a validation error and a hidden input named category.
Dependencies
Files
- components/ui/tree-select.tsx
FAQ
Is the React Tree Select component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Tree Select?
Run npx wingo-ui@latest add tree-select. It also adds the Wingo UI items it builds on (Tree View, Field, Drawer, Tag, Button and 5 more) and the npm packages @radix-ui/react-popover, motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/tree-select.
How does Tree Select behave on phones?
A bottom sheet below 768px, with the search pinned under the title, 44px rows and (multiple) a Done button. The responsive prop controls this and is on by default.