Navigation
File Tree
An accessible, keyboard-navigable hierarchical file/folder tree with expand-collapse disclosure and controlled selection.
Preview in your theme
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
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). role=group genuinely nests inside its
parent role=treeitem, so events from a deeply nested child bubble through
every ancestor folder. Put the click handler on the row header only
(nested rows live in the group, not the header), and record focus only
when e.target === e.currentTarget — so neither needs stopPropagation(),
and the consumer's own handlers around the tree still see every event.
- 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.
- A collapsed folder's group is `inert`: the 0fr row only clips visually,
and without inert screen readers in browse mode would still read (and
count) every hidden treeitem. Children mount on a folder's first expand
and then stay mounted, so a large repo with only the root open renders
one row per top-level node, and collapsing still animates.
- 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 on the label row, driven by the
treeitem's own :focus-visible through a child selector
([&:focus-visible>div:first-child]:ring-2 …). Not the roving tab-stop
state: that marks the first row before anything is focused and keeps the
last-clicked row ringed after focus leaves. And not a plain
focus-visible ring on the treeitem, which would span 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. - Header-only activation — because a folder's children live inside its own DOM subtree, the click handler sits on the row header alone and focus is recorded only when the treeitem itself is the event target, so activating a deeply nested file never toggles or re-focuses its ancestor folders — without
stopPropagation(), which would also hide those events from the consumer.