Tree
Hierarchical data with expandable nodes, selection and keyboard navigation.
Newsrc
components
button.tsx
card.tsx
tree.tsx
hooks
index.ts
package.json
tsconfig.json
1'use client';23import { Tree } from '@/components/ui/tree';45export function Default() {6 return (7 <Tree defaultExpanded={['src', 'src/components']} className="max-w-xs">8 <Tree.Item value="src" label="src">9 <Tree.Item value="src/components" label="components">10 <Tree.Item value="src/components/button.tsx" label="button.tsx" />11 <Tree.Item value="src/components/card.tsx" label="card.tsx" />12 <Tree.Item value="src/components/tree.tsx" label="tree.tsx" />13 </Tree.Item>14 <Tree.Item value="src/hooks" label="hooks">15 <Tree.Item value="src/hooks/use-local-storage.ts" label="use-local-storage.ts" />16 </Tree.Item>17 <Tree.Item value="src/index.ts" label="index.ts" />18 </Tree.Item>19 <Tree.Item value="package.json" label="package.json" />20 <Tree.Item value="tsconfig.json" label="tsconfig.json" />21 </Tree>22 );23}
Installation
Copy and paste the following code into your project.
1'use client';23import { AnimatePresence, motion, useReducedMotion } from 'motion/react';4import * as React from 'react';5import { cn } from '../lib/cn';6import { collapse } from '../lib/motion';7import { useControllableState } from '../lib/use-controllable-state';89type IconProps = React.SVGProps<SVGSVGElement> & {10 size?: number | string;11};1213function ChevronRightIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {14 return (15 <svg16 xmlns="http://www.w3.org/2000/svg"17 width={size}18 height={size}19 viewBox="0 0 24 24"20 fill="none"21 stroke="currentColor"22 strokeWidth={strokeWidth}23 strokeLinecap="round"24 strokeLinejoin="round"25 aria-hidden="true"26 {...props}27 >28 <path d="m9 6 6 6-6 6" />29 </svg>30 );31}3233function MinusIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {34 return (35 <svg36 xmlns="http://www.w3.org/2000/svg"37 width={size}38 height={size}39 viewBox="0 0 24 24"40 fill="none"41 stroke="currentColor"42 strokeWidth={strokeWidth}43 strokeLinecap="round"44 strokeLinejoin="round"45 aria-hidden="true"46 {...props}47 >48 <path d="M5 12h14" />49 </svg>50 );51}5253function PlusIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {54 return (55 <svg56 xmlns="http://www.w3.org/2000/svg"57 width={size}58 height={size}59 viewBox="0 0 24 24"60 fill="none"61 stroke="currentColor"62 strokeWidth={strokeWidth}63 strokeLinecap="round"64 strokeLinejoin="round"65 aria-hidden="true"66 {...props}67 >68 <path d="M12 5v14" />69 <path d="M5 12h14" />70 </svg>71 );72}7374type ToggleIcon = 'chevron' | 'plus-minus';7576interface TreeSnapshot {77 expanded: ReadonlySet<string>;78 selected: string | undefined;79}8081interface TreeStore {82 subscribe: (listener: () => void) => () => void;83 get: () => TreeSnapshot;84 set: (snapshot: TreeSnapshot) => void;85}8687function createTreeStore(initial: TreeSnapshot): TreeStore {88 let snapshot = initial;89 const listeners = new Set<() => void>();90 return {91 subscribe: (listener) => {92 listeners.add(listener);93 return () => listeners.delete(listener);94 },95 get: () => snapshot,96 set: (next) => {97 if (next.expanded === snapshot.expanded && next.selected === snapshot.selected) return;98 snapshot = next;99 for (const listener of listeners) listener();100 },101 };102}103104interface TreeContextValue {105 store: TreeStore;106 toggleExpanded: (value: string) => void;107 select: (value: string) => void;108 indent: number;109 toggleIcon: ToggleIcon;110 showLines: boolean;111}112113interface TreeProps {114 children: React.ReactNode;115 className?: string;116 expanded?: string[];117 defaultExpanded?: string[];118 onExpandedChange?: (expanded: string[]) => void;119 defaultSelected?: string;120 selected?: string;121 onSelectedChange?: (value: string) => void;122 indent?: number;123 toggleIcon?: ToggleIcon;124 showLines?: boolean;125 label?: string;126}127128interface TreeItemProps {129 value: string;130 label: React.ReactNode;131 icon?: React.ReactNode;132 iconOpen?: React.ReactNode;133 disabled?: boolean;134 onSelect?: () => void;135 children?: React.ReactNode;136 className?: string;137}138139const TreeContext = React.createContext<TreeContextValue | null>(null);140const DepthContext = React.createContext(0);141142const useTreeContext = (): TreeContextValue => {143 const context = React.use(TreeContext);144 if (!context) throw new Error('Tree components must be used within Tree');145 return context;146};147148const EMPTY_EXPANDED: string[] = [];149const TYPEAHEAD_RESET_MS = 500;150151function isHidden(item: HTMLElement, root: HTMLElement) {152 const inert = item.parentElement?.closest('[inert]');153 return inert ? root.contains(inert) : false;154}155156function getVisibleItems(root: HTMLElement | null): HTMLElement[] {157 if (!root) return [];158 return Array.from(root.querySelectorAll<HTMLElement>('[role="treeitem"]')).filter(159 (item) => !isHidden(item, root),160 );161}162163function getRow(item: HTMLElement) {164 return item.querySelector<HTMLElement>(':scope > [data-tree-row]');165}166167function getLabelText(item: HTMLElement) {168 const label = item.querySelector(':scope > [data-tree-row] > [data-tree-label]');169 return (label?.textContent ?? '').trim().toLowerCase();170}171172function getParentItem(item: HTMLElement, root: HTMLElement): HTMLElement | null {173 const parent = item.parentElement?.closest<HTMLElement>('[role="treeitem"]');174 return parent && root.contains(parent) ? parent : null;175}176177const TreeRoot = ({178 children,179 className,180 expanded: controlledExpanded,181 defaultExpanded = EMPTY_EXPANDED,182 onExpandedChange,183 defaultSelected,184 selected: controlledSelected,185 onSelectedChange,186 indent = 16,187 toggleIcon = 'chevron',188 showLines = false,189 label = 'Tree',190}: TreeProps): React.JSX.Element => {191 const [expandedList, setExpandedList] = useControllableState<string[]>({192 value: controlledExpanded,193 defaultValue: defaultExpanded,194 onChange: onExpandedChange,195 });196 const [selected, select] = useControllableState<string | undefined>({197 value: controlledSelected,198 defaultValue: defaultSelected,199 onChange: (value) => {200 if (value !== undefined) onSelectedChange?.(value);201 },202 });203 const expanded = React.useMemo<ReadonlySet<string>>(() => new Set(expandedList), [expandedList]);204 const [store] = React.useState(() => createTreeStore({ expanded, selected }));205 const rootRef = React.useRef<HTMLDivElement>(null);206 const tabStopRef = React.useRef<HTMLElement | null>(null);207 const typeaheadRef = React.useRef({ query: '', at: 0 });208 const focusInsideRef = React.useRef(false);209210 React.useLayoutEffect(() => {211 store.set({ expanded, selected });212 }, [store, expanded, selected]);213214 const toggleExpanded = React.useCallback(215 (value: string) => {216 setExpandedList((previous) =>217 previous.includes(value) ? previous.filter((item) => item !== value) : [...previous, value],218 );219 },220 [setExpandedList],221 );222223 const expandMany = React.useCallback(224 (values: string[]) => {225 setExpandedList((previous) => {226 const added = values.filter((value) => !previous.includes(value));227 return added.length > 0 ? [...previous, ...added] : previous;228 });229 },230 [setExpandedList],231 );232233 const moveTabStop = React.useCallback((item: HTMLElement) => {234 const previous = tabStopRef.current;235 if (previous && previous !== item) previous.tabIndex = -1;236 item.tabIndex = 0;237 tabStopRef.current = item;238 }, []);239240 const syncTabStop = React.useCallback(() => {241 const root = rootRef.current;242 if (!root) return;243 const items = getVisibleItems(root);244 const active = document.activeElement;245 const stop =246 items.find((item) => item === active) ??247 items.find((item) => item.getAttribute('aria-selected') === 'true') ??248 items.find((item) => item === tabStopRef.current) ??249 items[0];250 for (const item of items) {251 if (item !== stop && item.tabIndex !== -1) item.tabIndex = -1;252 }253 const previous = tabStopRef.current;254 if (previous && previous !== stop) previous.tabIndex = -1;255 if (stop) stop.tabIndex = 0;256 tabStopRef.current = stop ?? null;257 }, []);258259 const handleKeyDown = React.useCallback(260 (e: React.KeyboardEvent<HTMLDivElement>) => {261 const root = rootRef.current;262 const target = e.target as HTMLElement;263 if (!root || target.getAttribute('role') !== 'treeitem') return;264265 const items = getVisibleItems(root);266 if (items.length === 0) return;267 const currentIndex = items.indexOf(target);268 const isDisabled = target.getAttribute('aria-disabled') === 'true';269270 let nextIndex: number | null = null;271 if (e.key === 'ArrowDown') {272 nextIndex = Math.min(currentIndex + 1, items.length - 1);273 } else if (e.key === 'ArrowUp') {274 nextIndex = Math.max(currentIndex - 1, 0);275 } else if (e.key === 'Home') {276 nextIndex = 0;277 } else if (e.key === 'End') {278 nextIndex = items.length - 1;279 } else if (e.key === 'ArrowLeft') {280 if (target.getAttribute('aria-expanded') === 'true' && !isDisabled) {281 e.preventDefault();282 getRow(target)?.click();283 return;284 }285 const parent = getParentItem(target, root);286 if (parent) {287 e.preventDefault();288 parent.focus();289 }290 return;291 } else if (e.key === 'ArrowRight') {292 if (target.getAttribute('aria-expanded') === 'false' && !isDisabled) {293 e.preventDefault();294 getRow(target)?.click();295 return;296 }297 if (target.getAttribute('aria-expanded') === 'true') {298 nextIndex = Math.min(currentIndex + 1, items.length - 1);299 }300 } else if (e.key === '*') {301 e.preventDefault();302 const siblings = Array.from(target.parentElement?.children ?? []).filter(303 (item): item is HTMLElement =>304 item instanceof HTMLElement &&305 item.getAttribute('role') === 'treeitem' &&306 item.getAttribute('aria-expanded') === 'false' &&307 item.getAttribute('aria-disabled') !== 'true',308 );309 expandMany(siblings.flatMap((item) => (item.dataset.value ? [item.dataset.value] : [])));310 return;311 } else if (e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {312 const typeahead = typeaheadRef.current;313 const now = Date.now();314 const key = e.key.toLowerCase();315 typeahead.query = now - typeahead.at > TYPEAHEAD_RESET_MS ? key : typeahead.query + key;316 typeahead.at = now;317 const repeated = typeahead.query.split('').every((char) => char === key);318 const query = repeated ? key : typeahead.query;319 const start = repeated ? currentIndex + 1 : currentIndex;320 const ordered = [...items.slice(start), ...items.slice(0, start)];321 const match = ordered.find((item) => getLabelText(item).startsWith(query));322 if (match) {323 e.preventDefault();324 match.focus();325 }326 return;327 }328329 if (nextIndex !== null && nextIndex !== currentIndex) {330 e.preventDefault();331 items[nextIndex]?.focus();332 }333 },334 [expandMany],335 );336337 React.useLayoutEffect(() => {338 if (rootRef.current?.contains(document.activeElement)) return;339 syncTabStop();340 }, [selected, syncTabStop]);341342 React.useEffect(() => {343 const root = rootRef.current;344 if (!root || typeof MutationObserver === 'undefined') return;345 const observer = new MutationObserver((records) => {346 const stop = tabStopRef.current;347 if (stop && !stop.isConnected) {348 const active = document.activeElement;349 if (focusInsideRef.current && (!active || active === document.body)) {350 const record = records.find((entry) =>351 Array.from(entry.removedNodes).some((node) => node.contains(stop)),352 );353 const owner =354 record?.target instanceof HTMLElement355 ? record.target.closest<HTMLElement>('[role="treeitem"]')356 : null;357 if (owner && root.contains(owner) && !isHidden(owner, root)) {358 owner.focus();359 return;360 }361 }362 }363 if (!stop || !stop.isConnected || isHidden(stop, root)) syncTabStop();364 });365 observer.observe(root, {366 childList: true,367 subtree: true,368 attributes: true,369 attributeFilter: ['inert'],370 });371 return () => observer.disconnect();372 }, [syncTabStop]);373374 const contextValue = React.useMemo(375 () => ({ store, toggleExpanded, select, indent, toggleIcon, showLines }),376 [store, toggleExpanded, select, indent, toggleIcon, showLines],377 );378379 return (380 <TreeContext value={contextValue}>381 <div382 ref={rootRef}383 role="tree"384 tabIndex={-1}385 aria-label={label}386 onKeyDown={handleKeyDown}387 onFocus={(e) => {388 focusInsideRef.current = true;389 const target = e.target as HTMLElement;390 if (target.getAttribute('role') === 'treeitem') moveTabStop(target);391 else if (!tabStopRef.current?.isConnected) syncTabStop();392 }}393 onBlur={(e) => {394 if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;395 if ((e.target as Node).isConnected) focusInsideRef.current = false;396 }}397 className={cn('w-full text-sm', className)}398 >399 {children}400 </div>401 </TreeContext>402 );403};404405const TreeItem = ({406 value,407 label,408 icon,409 iconOpen,410 disabled = false,411 onSelect,412 children,413 className,414}: TreeItemProps): React.JSX.Element => {415 const { store, toggleExpanded, select, indent, toggleIcon, showLines } = useTreeContext();416 const depth = React.use(DepthContext);417 const shouldReduceMotion = useReducedMotion();418 const labelId = React.useId();419 const itemRef = React.useRef<HTMLDivElement>(null);420 const groupRef = React.useRef<HTMLDivElement>(null);421422 const hasChildren = React.Children.count(children) > 0;423 const getOpen = () => store.get().expanded.has(value);424 const getSelected = () => store.get().selected === value;425 const isOpen = React.useSyncExternalStore(store.subscribe, getOpen, getOpen);426 const isSelected = React.useSyncExternalStore(store.subscribe, getSelected, getSelected);427 const showSelected = !hasChildren && isSelected;428429 React.useLayoutEffect(() => {430 const group = groupRef.current;431 if (!group) return;432 if (!isOpen && group.contains(document.activeElement)) itemRef.current?.focus();433 group.toggleAttribute('inert', !isOpen);434 }, [isOpen]);435436 const handleActivate = () => {437 if (disabled) return;438 if (hasChildren) {439 toggleExpanded(value);440 return;441 }442 select(value);443 onSelect?.();444 };445446 const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {447 const row = (e.target as Element).closest('[data-tree-row]');448 if (row?.parentElement !== e.currentTarget) return;449 handleActivate();450 };451452 const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {453 if (e.target !== e.currentTarget) return;454 if (e.key === 'Enter' || e.key === ' ') {455 e.preventDefault();456 handleActivate();457 }458 };459460 const resolvedIcon = isOpen && iconOpen !== undefined ? iconOpen : icon;461462 return (463 <div464 ref={itemRef}465 role="treeitem"466 tabIndex={-1}467 aria-labelledby={labelId}468 aria-expanded={hasChildren ? isOpen : undefined}469 aria-selected={hasChildren ? undefined : showSelected}470 aria-level={depth + 1}471 aria-disabled={disabled || undefined}472 data-tree-item=""473 data-value={value}474 onClick={handleClick}475 onKeyDown={handleKeyDown}476 className="[&:focus-visible>[data-tree-row]]:ring-ring focus-visible:outline-none [&:focus-visible>[data-tree-row]]:ring-1"477 >478 <div479 data-tree-row=""480 className={cn(481 'flex items-center gap-1.5 rounded-md px-1.5 py-1 select-none',482 'transition-colors motion-reduce:transition-none',483 disabled ? 'cursor-not-allowed opacity-40' : 'hover:bg-muted cursor-pointer',484 showSelected && 'bg-muted text-foreground font-medium',485 className,486 )}487 >488 {hasChildren ? (489 <span490 aria-hidden="true"491 className="text-muted-foreground flex size-4 shrink-0 items-center justify-center"492 >493 {toggleIcon === 'plus-minus' ? (494 isOpen ? (495 <MinusIcon size={12} />496 ) : (497 <PlusIcon size={12} />498 )499 ) : (500 <span501 className={cn(502 'transition-transform duration-200 motion-reduce:transition-none',503 isOpen && 'rotate-90',504 )}505 >506 <ChevronRightIcon size={16} />507 </span>508 )}509 </span>510 ) : (511 <span aria-hidden="true" className="size-4 shrink-0" />512 )}513514 {resolvedIcon !== undefined && (515 <span516 aria-hidden="true"517 className="text-muted-foreground flex shrink-0 items-center [&_svg]:size-4"518 >519 {resolvedIcon}520 </span>521 )}522523 <span id={labelId} data-tree-label="" className="min-w-0 flex-1 truncate">524 {label}525 </span>526 </div>527528 {hasChildren && (529 <AnimatePresence initial={false}>530 {isOpen && (531 <motion.div532 ref={groupRef}533 data-tree-group=""534 role="group"535 initial={shouldReduceMotion ? false : 'closed'}536 animate="open"537 exit={shouldReduceMotion ? undefined : 'closed'}538 variants={collapse}539 className={cn('ml-3.5 overflow-hidden', showLines && 'border-border border-l')}540 style={{ paddingLeft: Math.max(indent - 8, 4) }}541 >542 <DepthContext value={depth + 1}>{children}</DepthContext>543 </motion.div>544 )}545 </AnimatePresence>546 )}547 </div>548 );549};550551const Tree = Object.assign(TreeRoot, {552 Item: TreeItem,553});554555export { Tree };556export type { TreeItemProps, TreeProps };
Update the import paths to match your project structure.
Anatomy
1import { Tree } from '@/components/ui/tree';
1<Tree defaultExpanded={['src']}>2 <Tree.Item value="src" label="src">3 <Tree.Item value="src/index.ts" label="index.ts" />4 </Tree.Item>5 <Tree.Item value="package.json" label="package.json" />6</Tree>
An item with children is a branch: clicking it toggles expansion. An item without children is a leaf: clicking it selects it and fires
onSelectedChange on the root. Arrow keys walk the visible rows, ArrowRight expands, ArrowLeft collapses or jumps to the parent, Home and End jump to the edges.Composition
Use the following composition to build a
Tree:Treeโโโ Tree.Itemโโโ Tree.Item
Variants
Lines
showLines draws a guide line along each level.app
docs
elements
page.tsx
layout.tsx
page.tsx
layout.tsx
globals.css
proxy.ts
1'use client';23import { Tree } from '@/components/ui/tree';45export function Lines() {6 return (7 <Tree showLines defaultExpanded={['app', 'app/docs', 'app/docs/elements']} className="max-w-xs">8 <Tree.Item value="app" label="app">9 <Tree.Item value="app/docs" label="docs">10 <Tree.Item value="app/docs/elements" label="elements">11 <Tree.Item value="app/docs/elements/page.tsx" label="page.tsx" />12 <Tree.Item value="app/docs/elements/layout.tsx" label="layout.tsx" />13 </Tree.Item>14 <Tree.Item value="app/docs/page.tsx" label="page.tsx" />15 </Tree.Item>16 <Tree.Item value="app/layout.tsx" label="layout.tsx" />17 <Tree.Item value="app/globals.css" label="globals.css" />18 </Tree.Item>19 <Tree.Item value="proxy.ts" label="proxy.ts" />20 </Tree>21 );22}
Icons
Pass
icon for every node and iconOpen for branches that change while expanded, like folders.packages
ui
tree.tsx
frame.tsx
cli
turbo.json
1'use client';23import { Tree } from '@/components/ui/tree';4import { FileIcon } from '@/components/ui/file';5import { FolderIcon } from '@/components/ui/folder';6import { FolderOpenIcon } from '@/components/ui/folder-open';78const folderClosed = <FolderIcon size={16} />;9const folderOpen = <FolderOpenIcon size={16} />;10const file = <FileIcon size={16} />;1112export function Icons() {13 return (14 <Tree defaultExpanded={['packages', 'packages/ui']} className="max-w-xs">15 <Tree.Item value="packages" label="packages" icon={folderClosed} iconOpen={folderOpen}>16 <Tree.Item value="packages/ui" label="ui" icon={folderClosed} iconOpen={folderOpen}>17 <Tree.Item value="packages/ui/tree.tsx" label="tree.tsx" icon={file} />18 <Tree.Item value="packages/ui/frame.tsx" label="frame.tsx" icon={file} />19 </Tree.Item>20 <Tree.Item value="packages/cli" label="cli" icon={folderClosed} iconOpen={folderOpen}>21 <Tree.Item value="packages/cli/index.ts" label="index.ts" icon={file} />22 </Tree.Item>23 </Tree.Item>24 <Tree.Item value="turbo.json" label="turbo.json" icon={file} />25 </Tree>26 );27}
Plus and minus
toggleIcon="plus-minus" swaps the chevron for plus and minus toggles.content
docs
en
es
README.md
1'use client';23import { Tree } from '@/components/ui/tree';4import { FileIcon } from '@/components/ui/file';5import { FolderIcon } from '@/components/ui/folder';6import { FolderOpenIcon } from '@/components/ui/folder-open';78const folderClosed = <FolderIcon size={16} />;9const folderOpen = <FolderOpenIcon size={16} />;10const file = <FileIcon size={16} />;1112export function PlusMinus() {13 return (14 <Tree15 toggleIcon="plus-minus"16 showLines17 defaultExpanded={['content', 'content/docs']}18 className="max-w-xs"19 >20 <Tree.Item value="content" label="content" icon={folderClosed} iconOpen={folderOpen}>21 <Tree.Item value="content/docs" label="docs" icon={folderClosed} iconOpen={folderOpen}>22 <Tree.Item value="content/docs/en" label="en" icon={folderClosed} iconOpen={folderOpen}>23 <Tree.Item value="content/docs/en/tree.mdx" label="tree.mdx" icon={file} />24 </Tree.Item>25 <Tree.Item value="content/docs/es" label="es" icon={folderClosed} iconOpen={folderOpen}>26 <Tree.Item value="content/docs/es/tree.mdx" label="tree.mdx" icon={file} />27 </Tree.Item>28 </Tree.Item>29 </Tree.Item>30 <Tree.Item value="README.md" label="README.md" icon={file} />31 </Tree>32 );33}
Keyboard
| Key | Action |
|---|---|
ArrowDown / ArrowUp | Move to the next or previous visible item |
ArrowRight | Open a closed branch, or move into an open one |
ArrowLeft | Close an open branch, or move to the parent |
Home / End | Move to the first or last visible item |
Enter / Space | Select a leaf or toggle a branch |
* | Open every branch at the same level |
| Letters | Move to the next item whose label starts with what you typed |
API Reference
Tree
| Prop | Type | Default | Description |
|---|---|---|---|
expanded | string[] | - | Controlled list of expanded branch values |
defaultExpanded | string[] | [] | Branch values expanded on mount |
onExpandedChange | (expanded: string[]) => void | - | Callback when a branch opens or closes |
defaultSelected | string | - | Initially selected leaf |
selected | string | - | Controlled selected leaf |
onSelectedChange | (value: string) => void | - | Callback when a leaf is selected |
indent | number | 16 | Pixels of indentation per level |
toggleIcon | 'chevron' | 'plus-minus' | 'chevron' | Expand and collapse icon style |
showLines | boolean | false | Draw guide lines between levels |
label | string | 'Tree' | Accessible name for the tree |
className | string | - | Additional CSS classes |
Tree.Item
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | - | Unique value, used for expansion and selection |
label | ReactNode | - | Row content |
icon | ReactNode | - | Leading icon |
iconOpen | ReactNode | - | Icon while the branch is expanded |
disabled | boolean | false | Disable the row. It stays focusable with the arrow keys but can't be selected or toggled |
onSelect | () => void | - | Callback when this leaf is selected |
className | string | - | Additional CSS classes |
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.