Tree Select
tree-selectuiSelect from tree-structured data with expand/collapse nodes, single or multi-select with checkboxes, and search filtering. Dropdown shows a nested tree; parent selection cascades to leaf descendants.
When to use which
Decision GuideSelect is the headless primitive for flat options. Advance Select is the searchable combobox. Cascade and Tree selectors are designed specifically for hierarchical and nested datasets.
| Component | Role | Use when |
|---|---|---|
| Select Short, flat option lists (< 20 items) where typing to filter is unnecessary. | Primitive Dropdown | |
| AdvanceSelect Large datasets requiring fuzzy search, multi-select tag pills, or remote option filtering. | Searchable Combobox | |
| CascadeSelect Sequential multi-level drilldowns (e.g. Country > State > City, Category > Subcategory). | Hierarchical Picker | |
| TreeSelectcurrent Deeply nested taxonomy structures, folder trees, or parent-child permission nodes. | Tree Picker |
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/tree-select.json$npx shadcn@latest add https://uipkge.dev/r/react/tree-select.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/tree-select.json$bunx shadcn@latest add https://uipkge.dev/r/react/tree-select.jsonnpx shadcn@latest add @uipkge-react/tree-selectInstalls to:components/ui/tree-select/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
size | 'sm''default''lg' | default | optional |
value | string | string[] | null | — | optional |
defaultValue | string | string[] | null | — | optional |
data | TreeNode[] | — | required |
multiple | boolean | — | optional |
placeholder | string | — | optional |
searchable | boolean | — | optional |
disabled | boolean | — | optional |
loading | boolean | — | optional |
clearable | boolean | — | optional |
defaultExpandAll | boolean | — | optional |
emptyText | string | — | optional |
searchPlaceholder | string | — | optional |
className | string | — | optional |
onValueChange | (value: string | string[] | null) => void | — | optional |
onChange | (value: string | string[] | null) => void | — | optional |
onSelect | (node: TreeNode) => void | — | optional |
onClear | () => void | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
TreeSelectNodeinterface TreeSelectNode {
value: string
label: string
disabled?: boolean
children?: TreeSelectNode[]
[key: string]: unknown
}npm dependencies
Includes
Files installed (5)
components/ui/tree-select/TreeSelect.tsx11.8 kB
'use client' import * as React from 'react' import { ChevronDown, Loader2, Search, X } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { treeSelectTriggerVariants } from './tree-select.variants' import { TreeSelectNode } from './TreeSelectNode' import type { TreeSelectNode as TreeNode } from './types' export interface TreeSelectProps { value?: string | string[] | null defaultValue?: string | string[] | null data: TreeNode[] multiple?: boolean placeholder?: string searchable?: boolean disabled?: boolean loading?: boolean clearable?: boolean defaultExpandAll?: boolean size?: 'sm' | 'default' | 'lg' emptyText?: string searchPlaceholder?: string className?: string onValueChange?: (value: string | string[] | null) => void onChange?: (value: string | string[] | null) => void onSelect?: (node: TreeNode) => void onClear?: () => void } function collectAllExpandable(nodes: TreeNode[]): string[] { const ids: string[] = [] const walk = (list: TreeNode[]) => { for (const n of list) { if (n.children?.length) { ids.push(n.value) walk(n.children) } } } walk(nodes) return ids } function findNode(nodes: TreeNode[], value: string): TreeNode | undefined { for (const n of nodes) { if (n.value === value) return n if (n.children) { const found = findNode(n.children, value) if (found) return found } } return undefined } function findLabels(nodes: TreeNode[], values: string[]): string[] { return values.map((v) => findNode(nodes, v)?.label ?? v) } function collectLeafValues(node: TreeNode): string[] { if (!node.children?.length) return [node.value] const vals: string[] = [] for (const c of node.children) vals.push(...collectLeafValues(c)) return vals } const TreeSelect = React.forwardRef<HTMLButtonElement, TreeSelectProps>( ( { value, defaultValue, data, multiple = false, placeholder = 'Select...', searchable = true, disabled = false, loading = false, clearable = true, defaultExpandAll = false, size = 'default', emptyText = 'No results found.', searchPlaceholder = 'Search...', className, onValueChange, onChange, onSelect, onClear, }, ref, ) => { const isControlled = value !== undefined const [internalValue, setInternalValue] = React.useState<string | string[] | null>(defaultValue ?? null) const currentValue = isControlled ? value : internalValue const [isOpen, setIsOpen] = React.useState(false) const [search, setSearch] = React.useState('') const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => { if (defaultExpandAll) return new Set(collectAllExpandable(data)) return new Set() }) // Re-collect expandable when defaultExpandAll toggles or data changes. React.useEffect(() => { if (defaultExpandAll) setExpandedIds(new Set(collectAllExpandable(data))) }, [defaultExpandAll, data]) // Clear search when popover closes. React.useEffect(() => { if (!isOpen) setSearch('') }, [isOpen]) const selectedValues = React.useMemo<Set<string>>(() => { if (currentValue == null) return new Set() if (Array.isArray(currentValue)) return new Set(currentValue) return new Set([currentValue]) }, [currentValue]) const displayLabel = React.useMemo(() => { if (multiple) { const vals = Array.isArray(currentValue) ? currentValue : [] if (vals.length === 0) return placeholder const labels = findLabels(data, vals) if (labels.length <= 3) return labels.join(', ') return `${labels.slice(0, 3).join(', ')} +${labels.length - 3}` } if (currentValue == null) return placeholder const node = findNode(data, currentValue as string) return node?.label ?? String(currentValue) }, [multiple, currentValue, placeholder, data]) const hasValue = React.useMemo(() => { if (multiple) return Array.isArray(currentValue) && currentValue.length > 0 return currentValue != null }, [multiple, currentValue]) // Search filtering: a node is visible if it or any descendant matches. // Expand-on-match is applied in an effect — never setState inside useMemo. const filteredIds = React.useMemo<Set<string> | null>(() => { const q = search.trim().toLowerCase() if (!q) return null const visible = new Set<string>() const walk = (nodes: TreeNode[]): boolean => { let anyMatch = false for (const n of nodes) { const selfMatch = n.label.toLowerCase().includes(q) let childMatch = false if (n.children?.length) { childMatch = walk(n.children) } if (selfMatch || childMatch) { visible.add(n.value) anyMatch = true } } return anyMatch } walk(data) return visible }, [search, data]) // Auto-expand ancestors of search matches. React.useEffect(() => { if (!filteredIds || filteredIds.size === 0) return const q = search.trim().toLowerCase() if (!q) return setExpandedIds((prev) => { const next = new Set(prev) let changed = false const walk = (nodes: TreeNode[]): boolean => { let anyMatch = false for (const n of nodes) { const selfMatch = n.label.toLowerCase().includes(q) let childMatch = false if (n.children?.length) childMatch = walk(n.children) if (selfMatch || childMatch) { anyMatch = true if (childMatch && !next.has(n.value)) { next.add(n.value) changed = true } } } return anyMatch } walk(data) return changed ? next : prev }) }, [filteredIds, search, data]) function commit(next: string | string[] | null) { if (!isControlled) setInternalValue(next) onValueChange?.(next) onChange?.(next) } function toggleNode(node: TreeNode) { if (node.disabled) return setExpandedIds((prev) => { const next = new Set(prev) if (next.has(node.value)) next.delete(node.value) else next.add(node.value) return next }) } function selectNode(node: TreeNode) { if (node.disabled) return if (!multiple) { commit(node.value) onSelect?.(node) setIsOpen(false) return } // Multi-select: toggle. For parent nodes, toggle all leaf descendants. const current = Array.isArray(currentValue) ? [...currentValue] : [] const leaves = collectLeafValues(node) const allSelected = leaves.every((v) => current.includes(v)) let next: string[] if (allSelected) { next = current.filter((v) => !leaves.includes(v)) } else { next = [...current, ...leaves.filter((v) => !current.includes(v))] } commit(next) onSelect?.(node) } function clearAll(event?: React.MouseEvent | React.KeyboardEvent) { event?.stopPropagation() if (disabled) return onClear?.() if (multiple) { commit([]) } else { commit(null) } } const triggerClasses = cn(treeSelectTriggerVariants({ size }), className) return ( <Popover open={isOpen} onOpenChange={setIsOpen}> <PopoverTrigger asChild> <button ref={ref} type="button" role="combobox" aria-expanded={isOpen} disabled={disabled || loading} data-uipkge="" data-slot="tree-select" className={triggerClasses} > <span className={cn('flex-1 truncate text-left', hasValue ? 'text-foreground' : 'text-muted-foreground')}> {displayLabel} </span> <span className="flex shrink-0 items-center gap-1"> {loading ? ( <Loader2 className="text-muted-foreground size-4 animate-spin" /> ) : clearable && hasValue && !disabled ? ( <span role="button" tabIndex={0} aria-label="Clear" className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex size-4 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none" onClick={(e) => clearAll(e)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() clearAll(e) } }} > <X className="size-4" /> </span> ) : ( <ChevronDown className={cn( 'text-muted-foreground size-4 shrink-0 transition-transform duration-200', isOpen && 'rotate-180', )} /> )} </span> </button> </PopoverTrigger> <PopoverContent className="p-0" align="start" sideOffset={4} style={{ width: 'var(--radix-popover-trigger-width)' }} > <div className="flex max-h-80 flex-col"> {searchable && ( <div className="border-b p-2"> <div className="relative"> <Search className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" /> <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={searchPlaceholder} aria-label="Search tree" className="border-input focus-visible:ring-ring/50 h-9 w-full rounded-md border bg-transparent pl-8 text-sm shadow-xs outline-none focus-visible:ring-[3px]" /> </div> </div> )} {loading ? ( <div className="text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm"> <Loader2 className="size-4 animate-spin" /> Loading... </div> ) : data.length === 0 || (filteredIds && filteredIds.size === 0) ? ( <div className="text-muted-foreground py-6 text-center text-sm">{emptyText}</div> ) : ( <div className="flex-1 overflow-y-auto p-1" role="tree"> {data.map((node) => ( <TreeSelectNode key={node.value} node={node} depth={0} multiple={multiple} expandedIds={expandedIds} selectedValues={selectedValues} filteredIds={filteredIds} onToggle={toggleNode} onSelect={selectNode} /> ))} </div> )} {multiple && Array.isArray(currentValue) && currentValue.length > 0 && ( <div className="flex items-center justify-between border-t px-2 py-1.5 text-xs"> <span className="text-muted-foreground">{currentValue.length} selected</span> <button type="button" className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded focus-visible:ring-2 focus-visible:outline-none" onClick={() => clearAll()} > Clear all </button> </div> )} </div> </PopoverContent> </Popover> ) }, ) TreeSelect.displayName = 'TreeSelect' export { TreeSelect }components/ui/tree-select/TreeSelectNode.tsx7.5 kB
'use client' import * as React from 'react' import { ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import type { TreeSelectNode as TreeSelectNodeData } from './types' export interface TreeSelectNodeProps { node: TreeSelectNodeData depth: number multiple: boolean expandedIds: Set<string> selectedValues: Set<string> filteredIds: Set<string> | null parentValue?: string | null onToggle: (node: TreeSelectNodeData) => void onSelect: (node: TreeSelectNodeData) => void } function collectValues(node: TreeSelectNodeData): string[] { const vals: string[] = [] const walk = (n: TreeSelectNodeData) => { if (n.children?.length) { for (const c of n.children) walk(c) } else { vals.push(n.value) } } walk(node) return vals } function getTreeRows(from: HTMLElement): HTMLElement[] { const tree = from.closest('[role="tree"]') if (!tree) return [] return Array.from(tree.querySelectorAll<HTMLElement>('[data-tree-row]:not([data-disabled="true"])')) } function focusRow(row: HTMLElement | null | undefined) { row?.focus() } export const TreeSelectNode = React.memo( React.forwardRef<HTMLDivElement, TreeSelectNodeProps>( ( { node, depth, multiple, expandedIds, selectedValues, filteredIds, parentValue = null, onToggle, onSelect }, ref, ) => { const hasChildren = !!(node.children && node.children.length) const isExpanded = expandedIds.has(node.value) const isChecked = React.useMemo(() => { if (!multiple) return selectedValues.has(node.value) if (selectedValues.has(node.value)) return true if (!hasChildren) return false const descendants = collectValues(node) const selected = descendants.filter((v) => selectedValues.has(v)) return selected.length > 0 && selected.length < descendants.length }, [multiple, selectedValues, node, hasChildren]) const isFullyChecked = React.useMemo(() => { if (!multiple) return false if (selectedValues.has(node.value)) return true if (!hasChildren) return false const descendants = collectValues(node) return descendants.length > 0 && descendants.every((v) => selectedValues.has(v)) }, [multiple, selectedValues, node, hasChildren]) const isVisible = !filteredIds || filteredIds.has(node.value) const isSelected = !multiple ? selectedValues.has(node.value) : isFullyChecked || isChecked const indent = `${depth * 20 + 8}px` function handleToggle(e: React.MouseEvent) { e.stopPropagation() onToggle(node) } function handleSelect() { if (node.disabled) return onSelect(node) } function handleCheckboxChange(e: React.ChangeEvent<HTMLInputElement>) { e.stopPropagation() if (node.disabled) return onSelect(node) } function handleRowKeydown(e: React.KeyboardEvent) { if (node.disabled) return const target = e.currentTarget as HTMLElement if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleSelect() return } if (e.key === 'ArrowRight') { e.preventDefault() if (hasChildren && !isExpanded) { onToggle(node) } else if (hasChildren && isExpanded) { const rows = getTreeRows(target) const idx = rows.indexOf(target) if (idx >= 0 && idx < rows.length - 1) focusRow(rows[idx + 1]) } return } if (e.key === 'ArrowLeft') { e.preventDefault() if (hasChildren && isExpanded) { onToggle(node) } else if (parentValue) { const tree = target.closest('[role="tree"]') const parent = tree?.querySelector<HTMLElement>( `[data-tree-row][data-tree-id="${CSS.escape(parentValue)}"]`, ) focusRow(parent) } return } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() const rows = getTreeRows(target) const idx = rows.indexOf(target) if (idx < 0) return focusRow(e.key === 'ArrowDown' ? rows[idx + 1] : rows[idx - 1]) return } if (e.key === 'Home') { e.preventDefault() focusRow(getTreeRows(target)[0]) return } if (e.key === 'End') { e.preventDefault() const rows = getTreeRows(target) focusRow(rows[rows.length - 1]) } } if (!isVisible) return null return ( <div ref={ref} role="treeitem" aria-expanded={hasChildren ? isExpanded : undefined} aria-selected={isSelected}> <div data-tree-row data-tree-id={node.value} data-tree-parent={parentValue ?? undefined} data-disabled={node.disabled ? 'true' : undefined} className={cn( 'group relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md pr-2 text-sm transition-colors', 'hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none', node.disabled && 'cursor-not-allowed opacity-50', !multiple && selectedValues.has(node.value) && 'bg-accent text-accent-foreground font-medium', )} style={{ paddingLeft: indent }} tabIndex={node.disabled ? -1 : 0} onClick={handleSelect} onKeyDown={handleRowKeydown} > {hasChildren ? ( <button type="button" className={cn( 'focus-visible:ring-ring flex size-4 shrink-0 items-center justify-center rounded transition-transform duration-150 focus-visible:ring-2 focus-visible:outline-none', 'hover:bg-foreground/10', isExpanded && 'rotate-90', )} aria-label={isExpanded ? 'Collapse' : 'Expand'} tabIndex={-1} onClick={handleToggle} > <ChevronRight className="text-muted-foreground size-3.5" aria-hidden="true" /> </button> ) : ( <span className="size-4 shrink-0" /> )} {multiple && ( <input type="checkbox" checked={isFullyChecked || isChecked} ref={(el) => { if (el) el.indeterminate = isChecked && !isFullyChecked }} disabled={node.disabled} className="border-input text-primary focus:ring-ring size-3.5 shrink-0 rounded focus:ring-1" onChange={handleCheckboxChange} onClick={(e) => e.stopPropagation()} /> )} <span className="flex-1 truncate">{node.label}</span> </div> {hasChildren && isExpanded && ( <div role="group"> {node.children!.map((child) => ( <TreeSelectNode key={child.value} node={child} depth={depth + 1} multiple={multiple} expandedIds={expandedIds} selectedValues={selectedValues} filteredIds={filteredIds} parentValue={node.value} onToggle={onToggle} onSelect={onSelect} /> ))} </div> )} </div> ) }, ), ) TreeSelectNode.displayName = 'TreeSelectNode'components/ui/tree-select/tree-select.variants.ts0.7 kB
import { cva } from 'class-variance-authority' export const treeSelectTriggerVariants = cva( 'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent text-sm shadow-xs transition-[color,box-shadow] outline-none hover:border-ring/50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', { variants: { size: { sm: 'h-8 text-xs px-2.5', default: 'h-9 text-sm px-3', lg: 'h-11 text-base px-4', }, }, defaultVariants: { size: 'default', }, }, ) export type TreeSelectVariants = ReturnType<typeof treeSelectTriggerVariants>components/ui/tree-select/types.ts0.1 kB
export interface TreeSelectNode { value: string label: string disabled?: boolean children?: TreeSelectNode[] [key: string]: unknown }components/ui/tree-select/index.ts0.5 kB
export { TreeSelect, type TreeSelectProps } from './TreeSelect' // The data type gets the canonical `TreeSelectNode` name (consumers type their // trees with it); the internal row component stays reachable under an alias. export { TreeSelectNode as TreeSelectNodeComponent, type TreeSelectNodeProps } from './TreeSelectNode' export { treeSelectTriggerVariants, type TreeSelectVariants } from './tree-select.variants' export type { TreeSelectNode } from './types'
Raw manifest:https://uipkge.dev/r/react/tree-select.json