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-svelte@latest add https://uipkge.dev/r/svelte/tree-select.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tree-select.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tree-select.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tree-select.jsonhttps://uipkge.dev/r/svelte/tree-select.jsonInstalls to:src/lib/components/ui/tree-select/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
size | 'sm''default''lg' | default | optional |
valueRenamed from Vue's `modelValue`: the Svelte twin binds `value`. | string | string[] | null | — | optional |
data | TreeNode[] | — | required |
multiple | boolean | false | optional |
placeholder | string | Select... | optional |
searchable | boolean | true | optional |
disabled | boolean | false | optional |
loading | boolean | false | optional |
clearable | boolean | true | optional |
defaultExpandAll | boolean | false | optional |
emptyText | string | No results found. | optional |
searchPlaceholder | string | Search... | optional |
onselect | (node: TreeNode) => void | — | optional |
onchange | (value: string | string[] | null) => void | — | optional |
onValueChangeReact parity alias for `onchange` — both fire on every commit. | (value: string | string[] | null) => void | — | optional |
onclear | () => void | — | optional |
ref | HTMLButtonElement | null | — | 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)
src/lib/components/ui/tree-select/TreeSelect.svelte10.9 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { TreeSelectNode as TreeNode } from './types' // Omit DOM event props shadowed by component callbacks (payloads, not events). export interface TreeSelectProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'onchange' | 'onselect'> { /** Renamed from Vue's `modelValue`: the Svelte twin binds `value`. */ value?: 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 onselect?: (node: TreeNode) => void onchange?: (value: string | string[] | null) => void /** React parity alias for `onchange` — both fire on every commit. */ onValueChange?: (value: string | string[] | null) => void onclear?: () => void ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { untrack } from 'svelte' import { ChevronDown, Loader2, Search, X } from '@lucide/svelte' import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover' import { cn } from '$lib/utils' import { treeSelectTriggerVariants } from './tree-select.variants' import TreeSelectNode from './TreeSelectNode.svelte' let { value = $bindable(), data, multiple = false, placeholder = 'Select...', searchable = true, disabled = false, loading = false, clearable = true, defaultExpandAll = false, size = 'default', emptyText = 'No results found.', searchPlaceholder = 'Search...', onselect, onchange, onValueChange, onclear, class: className, ref = $bindable(null), ...restProps }: TreeSelectProps = $props() function emitChange(next: string | string[] | null) { onchange?.(next) onValueChange?.(next) } let isOpen = $state(false) let search = $state('') let expandedIds = $state<Set<string>>(new Set()) 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 } $effect(() => { const expand = defaultExpandAll // Mirror the Vue watcher: react to the flag only, not to data identity. if (expand) expandedIds = new Set(collectAllExpandable(untrack(() => data))) }) const selectedValues = $derived.by((): Set<string> => { if (value == null) return new Set() if (Array.isArray(value)) return new Set(value) return new Set([value]) }) function findNode(nodes: TreeNode[], val: string): TreeNode | undefined { for (const n of nodes) { if (n.value === val) return n if (n.children) { const found = findNode(n.children, val) if (found) return found } } return undefined } function findLabels(nodes: TreeNode[], values: string[]): string[] { return values.map((v) => findNode(nodes, v)?.label ?? v) } const displayLabel = $derived.by(() => { if (multiple) { const vals = Array.isArray(value) ? value : [] 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 (value == null) return placeholder const node = findNode(data, value as string) return node?.label ?? String(value) }) const hasValue = $derived.by(() => { if (multiple) return Array.isArray(value) && value.length > 0 return value != null }) // Search filtering: a node is visible if it or any descendant matches. // Expand-on-match is applied in a separate effect — never mutate state inside a derived. const filteredIds = $derived.by((): 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 }) // Auto-expand ancestors of search matches without side effects in the filter derived. $effect(() => { const visible = filteredIds if (!visible || visible.size === 0) return const q = search.trim().toLowerCase() if (!q) return const next = new Set(expandedIds) 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(untrack(() => data)) if (changed) expandedIds = next }) function toggleNode(node: TreeNode) { if (node.disabled) return const next = new Set(expandedIds) if (next.has(node.value)) next.delete(node.value) else next.add(node.value) expandedIds = next } 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 } function selectNode(node: TreeNode) { if (node.disabled) return if (!multiple) { value = node.value emitChange(node.value) onselect?.(node) isOpen = false return } // Multi-select: toggle. For parent nodes, toggle all leaf descendants. const current = Array.isArray(value) ? [...value] : [] 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))] } value = next emitChange(next) onselect?.(node) } function clearAll(event?: Event) { event?.stopPropagation() if (disabled) return onclear?.() if (multiple) { value = [] emitChange([]) } else { value = null emitChange(null) } } function onClearKeydown(e: KeyboardEvent) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() clearAll() } } $effect(() => { if (!isOpen) search = '' }) // Match the panel width to the trigger (the Vue twin reads the popover's // trigger-width CSS var; measuring locally keeps this port self-contained). let triggerWidth = $state<number | null>(null) $effect(() => { const el = ref if (!el || typeof ResizeObserver === 'undefined') return const ro = new ResizeObserver(() => { triggerWidth = el.getBoundingClientRect().width }) ro.observe(el) triggerWidth = el.getBoundingClientRect().width return () => ro.disconnect() }) const contentStyle = $derived(triggerWidth ? `width: ${triggerWidth}px` : undefined) const triggerClasses = $derived(cn(treeSelectTriggerVariants({ size }), className)) </script> <Popover bind:open={isOpen}> <PopoverTrigger> <button bind:this={ref} type="button" role="combobox" aria-expanded={isOpen} disabled={disabled || loading} data-uipkge data-slot="tree-select" class={triggerClasses} {...restProps} > <span class="flex-1 truncate text-left {hasValue ? 'text-foreground' : 'text-muted-foreground'}"> {displayLabel} </span> <span class="flex shrink-0 items-center gap-1"> {#if loading} <Loader2 class="size-4 animate-spin text-muted-foreground" /> {:else if clearable && hasValue && !disabled} <span role="button" tabindex="0" aria-label="Clear" class="flex size-4 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none" onclick={clearAll} onkeydown={onClearKeydown} > <X class="size-4" /> </span> {:else} <ChevronDown class="size-4 shrink-0 text-muted-foreground transition-transform duration-200 {isOpen ? 'rotate-180' : ''}" /> {/if} </span> </button> </PopoverTrigger> <PopoverContent class="p-0" align="start" sideOffset={4} style={contentStyle}> <div class="flex max-h-80 flex-col"> {#if searchable} <div class="border-b p-2"> <div class="relative"> <Search class="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" /> <input bind:value={search} placeholder={searchPlaceholder} aria-label="Search tree" class="h-9 w-full rounded-md border border-input bg-transparent pl-8 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" /> </div> </div> {/if} {#if loading} <div class="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground"> <Loader2 class="size-4 animate-spin" /> Loading... </div> {:else if data.length === 0 || (filteredIds && filteredIds.size === 0)} <div class="py-6 text-center text-sm text-muted-foreground"> {emptyText} </div> {:else} <div class="flex-1 overflow-y-auto p-1" role="tree"> {#each data as node (node.value)} <TreeSelectNode {node} depth={0} {multiple} {expandedIds} {selectedValues} {filteredIds} ontoggle={toggleNode} onselect={selectNode} /> {/each} </div> {/if} {#if multiple && Array.isArray(value) && value.length} <div class="flex items-center justify-between border-t px-2 py-1.5 text-xs"> <span class="text-muted-foreground">{value.length} selected</span> <button type="button" class="rounded text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none" onclick={() => clearAll()} > Clear all </button> </div> {/if} </div> </PopoverContent> </Popover>src/lib/components/ui/tree-select/TreeSelectNode.svelte6.9 kB
<script lang="ts" module> import type { TreeSelectNode } from './types' export interface TreeSelectNodeRowProps { node: TreeSelectNode depth: number multiple: boolean expandedIds: Set<string> selectedValues: Set<string> filteredIds: Set<string> | null parentValue?: string | null ontoggle?: (node: TreeSelectNode) => void onselect?: (node: TreeSelectNode) => void } </script> <script lang="ts"> import { ChevronRight } from '@lucide/svelte' import { cn } from '$lib/utils' import TreeSelectNodeRow from './TreeSelectNode.svelte' let { node, depth, multiple, expandedIds, selectedValues, filteredIds, parentValue = null, ontoggle, onselect, }: TreeSelectNodeRowProps = $props() const hasChildren = $derived(!!(node.children && node.children.length)) const isExpanded = $derived(expandedIds.has(node.value)) const isChecked = $derived.by(() => { if (!multiple) return selectedValues.has(node.value) if (selectedValues.has(node.value)) return true // Indeterminate: some (not all) descendants selected if (!hasChildren) return false const descendants = collectValues(node) const selected = descendants.filter((v) => selectedValues.has(v)) return selected.length > 0 && selected.length < descendants.length }) const isFullyChecked = $derived.by(() => { 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)) }) const isVisible = $derived(!filteredIds || filteredIds.has(node.value)) const isSelected = $derived.by(() => { if (!multiple) return selectedValues.has(node.value) return isFullyChecked || isChecked }) function collectValues(root: TreeSelectNode): string[] { const vals: string[] = [] const walk = (n: TreeSelectNode) => { if (n.children?.length) { for (const c of n.children) walk(c) } else { vals.push(n.value) } } walk(root) return vals } function handleToggle(e: Event) { e.stopPropagation() ontoggle?.(node) } function handleSelect() { if (node.disabled) return onselect?.(node) } function handleCheckboxChange(e: Event) { e.stopPropagation() if (node.disabled) return onselect?.(node) } function indeterminate(el: HTMLInputElement, value: boolean) { el.indeterminate = value return { update(value: boolean) { el.indeterminate = value }, } } 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() } function handleRowKeydown(e: 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]) } } const indent = $derived(`calc(${depth} * var(--tree-indent) + var(--tree-indent-offset))`) </script> {#if isVisible} <div role="treeitem" aria-expanded={hasChildren ? isExpanded : undefined} aria-selected={isSelected} style="--tree-indent: 20px; --tree-indent-offset: 8px" > <div data-tree-row data-tree-id={node.value} data-tree-parent={parentValue ?? undefined} data-disabled={node.disabled ? 'true' : undefined} class={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-2 focus-visible:ring-ring/50 focus-visible:outline-none', node.disabled && 'cursor-not-allowed opacity-50', !multiple && selectedValues.has(node.value) && 'bg-accent text-accent-foreground font-medium', )} style="padding-left: {indent}" tabindex={node.disabled ? -1 : 0} onclick={handleSelect} onkeydown={handleRowKeydown} > {#if hasChildren} <button type="button" class={cn( 'flex size-4 shrink-0 items-center justify-center rounded transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', 'hover:bg-foreground/10', isExpanded && 'rotate-90', )} aria-label={isExpanded ? 'Collapse' : 'Expand'} tabindex="-1" onclick={handleToggle} > <ChevronRight class="size-3.5 text-muted-foreground" aria-hidden="true" /> </button> {:else} <span class="size-4 shrink-0"></span> {/if} {#if multiple} <input type="checkbox" checked={isFullyChecked || isChecked} use:indeterminate={isChecked && !isFullyChecked} disabled={node.disabled} class="size-3.5 shrink-0 rounded border-input text-primary focus:ring-1 focus:ring-ring" onchange={handleCheckboxChange} onclick={(e) => e.stopPropagation()} /> {/if} <span class="flex-1 truncate">{node.label}</span> </div> {#if hasChildren && isExpanded} <div role="group"> {#each node.children ?? [] as child (child.value)} <TreeSelectNodeRow node={child} depth={depth + 1} {multiple} {expandedIds} {selectedValues} {filteredIds} parentValue={node.value} {ontoggle} {onselect} /> {/each} </div> {/if} </div> {/if}src/lib/components/ui/tree-select/tree-select.variants.ts0.7 kB
import type { VariantProps } from 'class-variance-authority' 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 = VariantProps<typeof treeSelectTriggerVariants>src/lib/components/ui/tree-select/types.ts0.1 kB
export interface TreeSelectNode { value: string label: string disabled?: boolean children?: TreeSelectNode[] [key: string]: unknown }src/lib/components/ui/tree-select/index.ts0.5 kB
export { default as TreeSelect, type TreeSelectProps } from './TreeSelect.svelte' // The data type gets the canonical `TreeSelectNode` name (consumers type their // trees with it); the internal row component stays reachable under an alias. export { default as TreeSelectNodeComponent, type TreeSelectNodeRowProps, type TreeSelectNodeRowProps as TreeSelectNodeProps, } from './TreeSelectNode.svelte' export { treeSelectTriggerVariants, type TreeSelectVariants } from './tree-select.variants' export type { TreeSelectNode } from './types'
Raw manifest:https://uipkge.dev/r/svelte/tree-select.json