Navigation
File Tree
An accessible, keyboard-navigable hierarchical file/folder tree with expand-collapse disclosure and controlled selection.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/file-tree.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "FileTree" component using
lucide-react for icons.
Contract
- export const FileTree = React.forwardRef<HTMLDivElement, FileTreeProps>
rendering a <div role="tree">; spread remaining native div props on the
root, merge className via cn().
- TreeNode = { id: string; name: string; children?: TreeNode[] } — the
presence of `children` (even as an empty array) marks a node as a folder;
omitting it marks a file/leaf.
- items: TreeNode[] — the root-level nodes.
- defaultExpanded?: string[] — node ids expanded on mount; after that,
expand/collapse is internal state (an id Set), not re-driven by this prop.
- selected?: string | null (default null) + onSelect?: (id: string) => void
— selection is fully controlled by the consumer. The component never
falls back to its own "selected" state; it only calls onSelect(id) when a
file node is activated. Folders are never "selected", only expanded.
Behavior
- Implements the WAI-ARIA APG Tree View pattern with genuine DOM nesting:
the root is role="tree"; every node is a div role="treeitem" that
directly contains (a) its own visual label row and, for folders, (b) a
div role="group" wrapping the recursively rendered children — group is a
real DOM descendant of its owning treeitem, matching the ARIA spec's
reference markup. Folders carry aria-expanded; only file nodes carry
aria-selected (folders aren't part of the selection model, so they never
get that attribute at all).
- Roving tabindex: exactly one treeitem has tabIndex 0 at any time (the
"current" node) so Tab enters/exits the whole tree in a single stop;
every other node is tabIndex -1. Clicking or focusing a node updates
which one is "current".
- Keyboard, handled by a single delegated onKeyDown on the tree root and
computed against a depth-first flattened list of the currently *visible*
nodes (collapsed subtrees are skipped from that list entirely):
- ArrowDown / ArrowUp: move to the next/previous visible node.
- ArrowRight: expand a collapsed folder; if already expanded, move focus
into its first child instead. No-op on a file.
- ArrowLeft: collapse an expanded folder (focus stays on it); if already
collapsed or it's a file, move focus to the parent instead.
- Enter / Space: select a file (calls onSelect) or toggle a folder's
expand state.
- Home / End: jump to the first / last visible node.
- Click: clicking a folder's row toggles expand/collapse; clicking a
file's row calls onSelect(id). Both the click and focus handlers call
stopPropagation() — because role=group genuinely nests inside its parent
role=treeitem, a click or focus event on a deeply nested child would
otherwise bubble up and also fire the ancestor folders' handlers.
- Expand/collapse animates via the grid-template-rows 0fr↔1fr technique
(own transition, duration-150); prefers-reduced-motion drops the
transition so it snaps open/closed instantly instead — collapsing never
breaks the ability to reach the folder's contents, it just stops
animating.
- Icons: Folder/FolderOpen swap based on expand state; a lucide
ChevronRight rotates 90deg when expanded. Files render FileText and an
equal-width empty spacer takes the chevron's place, so file and folder
names stay vertically aligned regardless of type. All icons
aria-hidden="true".
- Indentation: each row's label gets an inline paddingLeft of
depth * 16 + 8 pixels — plain depth-based padding, no connector lines by
default (see Customization levers to add them).
Rendering & styling
- Semantic tokens only: row hover:bg-muted, selected state
bg-accent text-accent-foreground, icons text-muted-foreground, focus
indicator ring-2 ring-ring ring-inset (applied via JS-tracked focus
state on the label row, not the CSS :focus-visible pseudo-class, so the
ring never accidentally spans an expanded folder's entire subtree).
cn() merges className throughout. Row: py-1 pr-2 rounded-md text-sm;
icons size-4 shrink-0.
- No hex/oklch/rgb literals; no UI dependency beyond lucide-react.
Customization levers
- Icon-per-extension: replace the single FileText icon with a lookup
keyed by the file's extension (parsed from node.name), e.g.
Record<string, LucideIcon> mapping ".tsx" -> a code icon, ".json" -> a
braces icon, falling back to FileText.
- Connector lines: add a border-l (border-muted) on the nested group
wrapper or on each row to draw classic guide lines between indentation
levels — purely a rendering addition, no state changes needed.
- Async / lazy-loaded children: swap the "children !== undefined ⇒ folder"
check for an explicit isDir flag, fetch children on first expand, and
render a Skeleton row while that fetch is pending.
- Controlled expansion: promote the internal expanded Set<string> to a
controlled expanded/onExpandedChange pair if the consumer needs to
persist open folders or drive them from outside (e.g. syncing with a
router path).
- Density / row height: adjust py-1 and the gap-1.5 icon spacing for a
denser IDE-style tree, or increase them for a touch-friendly file picker.
- Drag-to-reorder is intentionally out of scope here — layer
@dnd-kit/core + @dnd-kit/sortable on top if nodes need to be
draggable/movable, keeping this component as the pure display/selection
layer underneath.Concepts
- WAI-ARIA tree pattern —
role="treeitem"genuinely nestsrole="group"for its children, matching the ARIA spec's reference markup exactly, so assistive tech gets accurate hierarchy without any extraaria-level/aria-posinsetbookkeeping. - Roving tabindex — only the "current" node sits in the page's Tab order (
tabIndex 0); arrow keys move a single internal focus pointer across nodes instead of tabbing through every row one by one. - Flattened visible-node list — the current expand state collapses to a depth-first array of only the currently visible nodes; every keyboard command walks that array, so collapsed subtrees are transparently skipped without special-casing.
- Path-as-identity — the demo's node ids double as full paths (
"src/components/button.tsx"), so a selected id is already a real, displayable path with no separate lookup step. - Stopped propagation on nested activation — because a folder's children live inside its own DOM subtree, click/focus handlers call
stopPropagation()so activating a deeply nested file never also toggles or re-focuses its ancestor folders.