UIPackage

Tree View

React data-display
Edit on GitHub

Indented 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

$ npx 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