Tree View
React data-displayIndented tree of expandable nodes — file browsers, taxonomy editors, nested settings. Discord-style elbow connectors, lazy-load branches, and full keyboard navigation.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/tree-view.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/tree-view.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/tree-view.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/tree-view.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/tree-view
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
items | TreeViewItem[] | — | required |
className | string | — | optional |
showIcons | boolean | — | optional |
showCheckboxes | boolean | — | optional |
defaultExpanded | boolean | — | optional |
selectedId | string | null | — | optional |
onSelect | (item: TreeViewItem) => void | — | optional |
onToggle | (item: TreeViewItem) => void | — | optional |
onSelectedIdChange | (id: string | null) => void | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
TreeViewContextValue interface TreeViewContextValue {
expandedIds: Set<string>
selectedId: string | null
showIcons: boolean
showCheckboxes: boolean
toggle: (item: TreeViewItem) => void
select: (item: TreeViewItem) => void
} TreeViewItem interface TreeViewItem {
id: string
label: string
icon?: LucideIcon
children?: TreeViewItem[]
disabled?: boolean
selected?: boolean
expanded?: boolean
[key: string]: unknown
} Dependencies
Files (4)
-
components/ui/tree-view/tree-view.tsx 7.2 kB
'use client' import * as React from 'react' import { ChevronRight, File, Folder, FolderOpen } from 'lucide-react' import { cn } from '@/lib/utils' import { TreeViewContext, type TreeViewContextValue } from './context' import type { TreeViewItem } from './types' export interface TreeViewProps { items: TreeViewItem[] className?: string showIcons?: boolean showCheckboxes?: boolean defaultExpanded?: boolean selectedId?: string | null onSelect?: (item: TreeViewItem) => void onToggle?: (item: TreeViewItem) => void onSelectedIdChange?: (id: string | null) => void } function collectExpandable(items: TreeViewItem[], acc: Set<string>) { for (const it of items) { if (it.children?.length) { acc.add(it.id) collectExpandable(it.children, acc) } } } const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>( ( { items, className, showIcons = true, showCheckboxes = false, defaultExpanded = false, selectedId: selectedIdProp = null, onSelect, onToggle, onSelectedIdChange, }, ref, ) => { const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => { if (!defaultExpanded) return new Set() const next = new Set<string>() collectExpandable(items, next) return next }) const [selectedId, setSelectedId] = React.useState<string | null>(selectedIdProp) // Mirror the Vue `watch(() => props.selectedId)` — keep internal state in // sync when the controlled prop changes. React.useEffect(() => { setSelectedId(selectedIdProp) }, [selectedIdProp]) const toggle = React.useCallback( (item: TreeViewItem) => { if (item.disabled) return setExpandedIds((prev) => { const next = new Set(prev) if (next.has(item.id)) next.delete(item.id) else next.add(item.id) return next }) onToggle?.(item) }, [onToggle], ) const select = React.useCallback( (item: TreeViewItem) => { if (item.disabled) return setSelectedId(item.id) onSelect?.(item) onSelectedIdChange?.(item.id) }, [onSelect, onSelectedIdChange], ) const ctx = React.useMemo<TreeViewContextValue>( () => ({ expandedIds, selectedId, showIcons, showCheckboxes, toggle, select }), [expandedIds, selectedId, showIcons, showCheckboxes, toggle, select], ) return ( <TreeViewContext.Provider value={ctx}> <div ref={ref} className={cn('text-sm', className)} role="tree"> {items.map((item, i) => ( <TreeViewNode key={item.id} item={item} depth={0} isLast={i === items.length - 1} /> ))} </div> </TreeViewContext.Provider> ) }, ) TreeView.displayName = 'TreeView' export interface TreeViewNodeProps { item: TreeViewItem depth: number isLast?: boolean } // 20px per level. Connector lives in parent's gutter (depth - 1). const INDENT = 20 function TreeViewNode({ item, depth, isLast = false }: TreeViewNodeProps) { const ctx = React.useContext(TreeViewContext) if (!ctx) throw new Error('TreeViewNode must be used inside <TreeView>') const isExpanded = ctx.expandedIds.has(item.id) const isSelected = ctx.selectedId === item.id const hasChildren = !!(item.children && item.children.length) let Icon: typeof File | null = null if (ctx.showIcons) { if (item.icon) Icon = item.icon else if (!hasChildren) Icon = File else Icon = isExpanded ? FolderOpen : Folder } const rowPadLeft = `${depth * INDENT + 4}px` const connectorLeft = `${(depth - 1) * INDENT + 10}px` const handleToggle = (e: React.MouseEvent) => { e.stopPropagation() ctx.toggle(item) } const handleSelect = () => { if (item.disabled) return ctx.select(item) } return ( <div role="treeitem" aria-expanded={hasChildren ? isExpanded : undefined} className="relative"> {/* Discord-style elbow + trunk for non-root nodes. The elbow points from the parent's chevron column down to this row's center; the trunk continues to the next sibling at this depth (omitted on the last sibling). */} {depth > 0 && ( <span aria-hidden="true" className="border-border pointer-events-none absolute top-0 h-4 w-3 rounded-bl-md border-b border-l" style={{ left: connectorLeft }} /> )} {depth > 0 && !isLast && ( <span aria-hidden="true" className="bg-border pointer-events-none absolute top-4 bottom-0 w-px" style={{ left: connectorLeft }} /> )} {/* Row */} <div 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', item.disabled && 'cursor-not-allowed opacity-50', isSelected && 'bg-accent text-accent-foreground font-medium', )} style={{ paddingLeft: rowPadLeft }} tabIndex={item.disabled ? -1 : 0} role="button" onClick={handleSelect} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleSelect() } }} > {/* Chevron (or 16px spacer for leaves so labels align) */} {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'} onClick={handleToggle} > <ChevronRight className="text-muted-foreground size-3.5" aria-hidden="true" /> </button> ) : ( <span className="size-4 shrink-0" /> )} {/* Checkbox */} {ctx.showCheckboxes && ( <input type="checkbox" checked={!!item.selected} disabled={item.disabled} className="border-input bg-background text-primary focus:ring-ring focus-visible:ring-ring size-3.5 shrink-0 rounded focus:ring-1 focus-visible:ring-2 focus-visible:outline-none" onChange={handleSelect} onClick={(e) => e.stopPropagation()} /> )} {/* Icon */} {Icon && ( <Icon className={cn('size-4 shrink-0', hasChildren ? 'text-primary' : 'text-muted-foreground')} /> )} {/* Label */} <span className="flex-1 truncate">{item.label}</span> </div> {/* Children */} {hasChildren && isExpanded && ( <div role="group"> {item.children!.map((child, j) => ( <TreeViewNode key={child.id} item={child} depth={depth + 1} isLast={j === (item.children?.length ?? 0) - 1} /> ))} </div> )} </div> ) } TreeViewNode.displayName = 'TreeViewNode' export { TreeView, TreeViewNode } -
components/ui/tree-view/context.ts 0.6 kB
import * as React from 'react' import type { TreeViewItem } from './types' export interface TreeViewContextValue { expandedIds: Set<string> selectedId: string | null showIcons: boolean showCheckboxes: boolean toggle: (item: TreeViewItem) => void select: (item: TreeViewItem) => void } // The Vue source threads the tree state from <TreeView> down to each // <TreeViewNode> via provide/inject. React's equivalent is context — the Root // sets it, every recursing node reads it. `null` default lets a node throw if // rendered outside a <TreeView>. export const TreeViewContext = React.createContext<TreeViewContextValue | null>(null) -
components/ui/tree-view/types.ts 0.2 kB
import type { LucideIcon } from 'lucide-react' export interface TreeViewItem { id: string label: string icon?: LucideIcon children?: TreeViewItem[] disabled?: boolean selected?: boolean expanded?: boolean [key: string]: unknown } -
components/ui/tree-view/index.ts 0.1 kB
export { TreeView, TreeViewNode, type TreeViewProps, type TreeViewNodeProps } from './tree-view' export { type TreeViewItem } from './types'
Raw manifest: https://react.uipkge.dev/r/react/tree-view.json