Tree View

Hierarchical tree component for nested data with controlled state, single/multi-select, and keyboard navigation. Install Tree View from the VLLNT UI registry with the shadcn CLI.

Report a bug

Hierarchical tree component for nested data with controlled state, single/multi-select, and keyboard navigation. Part of the data-display family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

Add Tree View to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/tree-view.json

Source

"use client"; import { type ComponentPropsWithoutRef, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useMemo, useState, } from "react"; import { cn } from "../../lib/utils"; /** * Selection mode for {@link TreeView}. * * @public */ export type TreeViewSelectionMode = "multiple" | "single"; /** * A node in the tree. * * @public */ export type TreeNode = { /** When `true`, the node renders dimmed and ignores clicks. */ disabled?: boolean; /** Optional leading icon. */ icon?: ReactNode; /** Stable identifier. */ id: string; /** Visible label. */ label: ReactNode; /** Child nodes; the node renders as a branch when present. */ nodes?: TreeNode[]; }; /** * Localizable strings. * * @public */ export type TreeViewLabels = { /** Aria-label for the tree. Defaults to `"Tree"`. */ region?: string; }; const DEFAULT_LABELS = { region: "Tree", } as const satisfies Required<TreeViewLabels>; /** * Props for {@link TreeView}. * * @public */ export type TreeViewProps = { /** Default expanded ids (uncontrolled). */ defaultExpanded?: string[]; /** Default selected ids (uncontrolled). */ defaultSelected?: string[]; /** Controlled expanded ids. */ expanded?: string[]; /** Localizable strings. */ labels?: TreeViewLabels; /** Tree data. */ nodes: TreeNode[]; /** Fires when expanded ids change. */ onExpandedChange?: (next: string[]) => void; /** Fires when selection changes. */ onSelect?: (next: string[]) => void; /** Controlled selected ids. */ selected?: string[]; /** Selection mode. Defaults to `"single"`. */ selectionMode?: TreeViewSelectionMode; } & Omit<ComponentPropsWithoutRef<"ul">, "onSelect"> & { ref?: React.Ref<HTMLUListElement>; }; type FlatNode = { depth: number; hasChildren: boolean; node: TreeNode; parentId?: string; }; type FlattenArguments = { depth?: number; expanded: ReadonlySet<string>; nodes: TreeNode[]; parentId?: string; }; function flattenVisible(arguments_: FlattenArguments): FlatNode[] { const { depth = 0, expanded, nodes, parentId } = arguments_; return nodes.flatMap((node) => { const hasChildren = (node.nodes?.length ?? 0) > 0; const head: FlatNode = { depth, hasChildren, node, parentId }; if (!hasChildren || !expanded.has(node.id)) return [head]; return [ head, ...flattenVisible({ depth: depth + 1, expanded, nodes: node.nodes ?? [], parentId: node.id, }), ]; }); } function useControlled<T>( controlled: T | undefined, defaultValue: T, ): readonly [T, (next: T) => void, boolean] { const [internal, setInternal] = useState<T>(defaultValue); const isControlled = controlled !== undefined; const value = isControlled ? controlled : internal; const setValue = useCallback( (next: T) => { if (!isControlled) setInternal(next); }, [isControlled], ); return [value, setValue, isControlled]; } function toggleSet(set: ReadonlySet<string>, id: string): string[] { const next = new Set(set); if (next.has(id)) next.delete(id); else next.add(id); return [...next]; } type TreeRowProps = { active: boolean; expanded: boolean; flat: FlatNode; onActivate: (id: string) => void; onExpand: (id: string) => void; onSelect: (id: string) => void; selected: boolean; }; function TreeRow({ active, expanded, flat, onActivate, onExpand, onSelect, selected, }: TreeRowProps): ReactNode { const { depth, hasChildren, node } = flat; const activate = (): void => { if (node.disabled) return; onActivate(node.id); if (hasChildren) onExpand(node.id); else onSelect(node.id); }; const handleKeyDown = (event: ReactKeyboardEvent<HTMLLIElement>): void => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); activate(); }; return ( <li aria-disabled={node.disabled || undefined} aria-expanded={hasChildren ? expanded : undefined} aria-selected={selected || undefined} className={cn( "flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-sm focus:outline-none", active ? "bg-accent text-accent-foreground" : "hover:bg-accent/60", selected ? "ring-1 ring-primary" : "", node.disabled ? "cursor-not-allowed opacity-50" : "", )} data-active={active ? "true" : undefined} data-depth={depth} data-node-id={node.id} data-selected={selected ? "true" : undefined} onClick={activate} onKeyDown={handleKeyDown} role="treeitem" style={{ paddingLeft: `${(depth * 16 + 8).toString()}px` }} > <span aria-hidden="true" className={cn( "inline-flex size-4 shrink-0 items-center justify-center text-xs text-muted-foreground transition-transform", hasChildren ? "" : "opacity-0", expanded ? "rotate-90" : "", )} > </span> {node.icon ? ( <span aria-hidden="true" className="inline-flex size-4 shrink-0"> {node.icon} </span> ) : null} <span className="truncate">{node.label}</span> </li> ); } function nextActiveId( flat: FlatNode[], delta: number, current?: string, ): string | undefined { if (flat.length === 0) return undefined; if (!current) return flat[0]?.node.id; const index = flat.findIndex((entry) => entry.node.id === current); if (index < 0) return flat[0]?.node.id; const nextIndex = Math.min(Math.max(index + delta, 0), flat.length - 1); return flat[nextIndex]?.node.id; } function findFlat(flat: FlatNode[], id?: string): FlatNode | undefined { if (!id) return undefined; return flat.find((entry) => entry.node.id === id); } function useTreeState(arguments_: { defaultExpanded?: string[]; defaultSelected?: string[]; expanded?: string[]; onExpandedChange?: (next: string[]) => void; onSelect?: (next: string[]) => void; selected?: string[]; selectionMode: TreeViewSelectionMode; }): { applyExpand: (id: string) => void; applySelect: (id: string) => void; expandedSet: ReadonlySet<string>; selectedSet: ReadonlySet<string>; } { const { defaultExpanded = [], defaultSelected = [], expanded, onExpandedChange, onSelect, selected, selectionMode, } = arguments_; const [expandedState, setExpandedState] = useControlled<string[]>( expanded, defaultExpanded, ); const [selectedState, setSelectedState] = useControlled<string[]>( selected, defaultSelected, ); const expandedSet = useMemo(() => new Set(expandedState), [expandedState]); const selectedSet = useMemo(() => new Set(selectedState), [selectedState]); const applyExpand = useCallback( (id: string) => { const next = toggleSet(expandedSet, id); setExpandedState(next); onExpandedChange?.(next); }, [expandedSet, onExpandedChange, setExpandedState], ); const applySelect = useCallback( (id: string) => { const next = selectionMode === "single" ? [id] : toggleSet(selectedSet, id); setSelectedState(next); onSelect?.(next); }, [onSelect, selectedSet, selectionMode, setSelectedState], ); return { applyExpand, applySelect, expandedSet, selectedSet }; } function useKeyboardHandler(arguments_: { activeId?: string; applyExpand: (id: string) => void; applySelect: (id: string) => void; expandedSet: ReadonlySet<string>; flat: FlatNode[]; setActiveId: (id?: string) => void; }): (event: ReactKeyboardEvent<HTMLUListElement>) => void { const { activeId, applyExpand, applySelect, expandedSet, flat, setActiveId } = arguments_; return useCallback( (event) => { const current = findFlat(flat, activeId); if (event.key === "ArrowDown") { event.preventDefault(); setActiveId(nextActiveId(flat, 1, activeId)); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveId(nextActiveId(flat, -1, activeId)); return; } if (event.key === "ArrowRight" && current?.hasChildren) { event.preventDefault(); if (!expandedSet.has(current.node.id)) applyExpand(current.node.id); return; } if (event.key === "ArrowLeft" && current) { event.preventDefault(); if (current.hasChildren && expandedSet.has(current.node.id)) { applyExpand(current.node.id); } else if (current.parentId) { setActiveId(current.parentId); } return; } if (event.key === "Enter" || event.key === " ") { if (!current || current.node.disabled) return; event.preventDefault(); if (current.hasChildren) applyExpand(current.node.id); else applySelect(current.node.id); } }, [activeId, applyExpand, applySelect, expandedSet, flat, setActiveId], ); } type TreeRowsProps = { activeId?: string; applyExpand: (id: string) => void; applySelect: (id: string) => void; expandedSet: ReadonlySet<string>; flat: FlatNode[]; selectedSet: ReadonlySet<string>; setActiveId: (id?: string) => void; }; function TreeRows({ activeId, applyExpand, applySelect, expandedSet, flat, selectedSet, setActiveId, }: TreeRowsProps): ReactNode { return ( <> {flat.map((entry) => ( <TreeRow active={entry.node.id === activeId} expanded={expandedSet.has(entry.node.id)} flat={entry} key={entry.node.id} onActivate={setActiveId} onExpand={applyExpand} onSelect={applySelect} selected={selectedSet.has(entry.node.id)} /> ))} </> ); } /** * Hierarchical tree component for nested data (file systems, categories, * org charts). Pass {@link TreeNode} data via `nodes`. Supports controlled * and uncontrolled expand/select state, single or multi-select, and full * keyboard navigation (arrows expand/collapse and traverse, enter/space * activates). * * @example * ```tsx * <TreeView * nodes={[ * { id: "src", label: "src/", nodes: [ * { id: "components", label: "components/" }, * { id: "utils", label: "utils/" }, * ]}, * ]} * defaultExpanded={["src"]} * onSelect={(ids) => console.info(ids)} * /> * ``` * * @public */ export const TreeView = (props: TreeViewProps) => { const { className, defaultExpanded, defaultSelected, expanded, labels, nodes, onExpandedChange, onSelect, ref, selected, selectionMode = "single", ...rest } = props; const resolvedLabels = useMemo( () => ({ ...DEFAULT_LABELS, ...labels }), [labels], ); const { applyExpand, applySelect, expandedSet, selectedSet } = useTreeState({ defaultExpanded, defaultSelected, expanded, onExpandedChange, onSelect, selected, selectionMode, }); const flat = useMemo( () => flattenVisible({ expanded: expandedSet, nodes }), [expandedSet, nodes], ); const [activeId, setActiveId] = useState<string | undefined>( () => flat[0]?.node.id, ); const handleKeyDown = useKeyboardHandler({ activeId, applyExpand, applySelect, expandedSet, flat, setActiveId, }); return ( <ul aria-label={resolvedLabels.region} aria-multiselectable={selectionMode === "multiple" || undefined} className={cn( "flex flex-col gap-0.5 rounded-2xl border bg-background p-2 text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring", className, )} onKeyDown={handleKeyDown} ref={ref} role="tree" tabIndex={0} {...rest} > <TreeRows activeId={activeId} applyExpand={applyExpand} applySelect={applySelect} expandedSet={expandedSet} flat={flat} selectedSet={selectedSet} setActiveId={setActiveId} /> </ul> ); }; TreeView.displayName = "TreeView";

Stories

Explore every variant and state in the interactive Storybook:

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Dependencies

  • @vllnt/ui@^0.3.0