File Tree
Hierarchical tree view for files, folders, and any nested navigation. Implements the WAI-ARIA tree pattern with role='tree' / 'treeitem' / 'group', aria-level, aria-expanded, aria-selected, and full keyboard navigation (Up/Down/Left/Right/Home/End/Enter/Space).
Project files (uncontrolled, expanded by default)
- src
- components
- button.tsx
- card.tsx
- input.tsx
- lib
- main.ts
- public
- package.json
- README.md
Settings nav (controlled selection)
- Account
- Profile
- Security
- Active sessions
- Workspace
- Members
- Billing
- Integrations
Selected: account/profile
Installation
pnpm dlx @hex-core/cli add file-treeWith search filter
Filter visible nodes by label substring. The recursion keeps a folder visible when any descendant matches. A single FileTree instance flips between controlled and uncontrolled expansion via `expanded={query ? matchedAncestorIds : undefined}` — `undefined` lets `defaultExpanded` take over so users can manually toggle folders when no query is active. One mount, no remount on the search-toggle transition.
import { useMemo, useState } from "react";
import { FileTree, type FileTreeNode } from "@/components/ui/file-tree";
import { Input } from "@/components/ui/input";
const initialNodes: FileTreeNode[] = [
{
id: "src",
name: "src",
children: [
{ id: "src/button.tsx", name: "button.tsx" },
{ id: "src/input.tsx", name: "input.tsx" },
{
id: "src/forms",
name: "forms",
children: [
{ id: "src/forms/checkbox.tsx", name: "checkbox.tsx" },
{ id: "src/forms/switch.tsx", name: "switch.tsx" },
],
},
],
},
{ id: "README.md", name: "README.md" },
];
function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] {
const needle = query.toLowerCase();
return nodes.flatMap((node) => {
const selfMatch = node.name.toLowerCase().includes(needle);
const childMatches = node.children ? filterTree(node.children, query) : [];
if (selfMatch) return [node];
if (childMatches.length > 0) return [{ ...node, children: childMatches }];
return [];
});
}
function collectFolderIds(nodes: FileTreeNode[]): string[] {
return nodes.flatMap((node) =>
node.children ? [node.id, ...collectFolderIds(node.children)] : [],
);
}
export function FilterableTree() {
const [query, setQuery] = useState("");
const filtered = useMemo(
() => (query ? filterTree(initialNodes, query) : initialNodes),
[query],
);
// While a query is active we force every matched ancestor open via the
// controlled `expanded` prop; passing `undefined` hands control back to
// `defaultExpanded` so users can manually toggle folders without remount.
const expanded = useMemo(
() => (query ? collectFolderIds(filtered) : undefined),
[query, filtered],
);
return (
<div className="space-y-2">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter files..."
aria-label="Filter files"
/>
<FileTree
aria-label="Project files"
nodes={filtered}
expanded={expanded}
defaultExpanded={["src"]}
/>
</div>
);
}Compact with custom icons
Override the per-node icon based on file extension. The default folder/file glyph still applies anywhere icon is omitted.
import { FileTree, type FileTreeNode } from "@/components/ui/file-tree";
function TsxIcon() {
return (
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor" aria-hidden="true">
<path d="M2 2h12v12H2z" opacity="0.1" />
<text x="8" y="11" fontSize="6" fontWeight="700" textAnchor="middle" fill="currentColor">TSX</text>
</svg>
);
}
function JsonIcon() {
return (
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M5 3c-1.5 0-2 .8-2 2v2c0 1-.5 1.5-1.5 1.5C2.5 8.5 3 9 3 10v2c0 1.2.5 2 2 2" />
<path d="M11 3c1.5 0 2 .8 2 2v2c0 1 .5 1.5 1.5 1.5-1 0-1.5.5-1.5 1.5v2c0 1.2-.5 2-2 2" />
</svg>
);
}
const nodes: FileTreeNode[] = [
{
id: "app",
name: "app",
children: [
{ id: "app/page.tsx", name: "page.tsx", icon: <TsxIcon /> },
{ id: "app/layout.tsx", name: "layout.tsx", icon: <TsxIcon /> },
],
},
{ id: "package.json", name: "package.json", icon: <JsonIcon /> },
{ id: "tsconfig.json", name: "tsconfig.json", icon: <JsonIcon /> },
];
export function CompactTree() {
return (
<FileTree
aria-label="Source"
nodes={nodes}
defaultExpanded={["app"]}
className="text-xs"
/>
);
}API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
nodesrequired | object | — | Tree of { id, name, children?, icon?, disabled? }. Presence of `children` (even an empty array) marks the node as a folder. |
defaultExpanded | object | — | Uncontrolled — initial expanded ids (string[]). |
expanded | object | — | Controlled expanded ids (string[]). Pair with onExpandedChange. |
onExpandedChange | function | — | Fired with the new expanded ids: (ids: string[]) => void |
selected | string | — | Controlled selected node id. |
onSelect | function | — | Fired when the user activates a node via click, Enter, or Space: (id: string) => void |
aria-labelrequired | string | — | Required accessible name for the tree (e.g. 'File explorer', 'Settings sections'). |
AI Guidance
When to use
Use for hierarchical navigation: file/folder explorers, settings sections, org charts, taxonomy browsers. Renders a real ARIA tree with full keyboard support, so it works for sighted, keyboard, and screen-reader users.
When not to use
Don't use for flat lists (use ScrollArea + a list). Don't use for navigation menus (use NavigationMenu). Don't use for very deep trees (>5 levels) without virtualization — every node is rendered. Don't use for selecting multiple files concurrently — multi-select tree UX is a different beast; ship a separate component when you need it.
Common mistakes
- Mixing controlled `expanded` with `defaultExpanded` — pass exactly one
- Using non-stable node ids (e.g. array index) — collapsing/expanding shifts state
- Marking a leaf with `children: []` instead of omitting `children` — empty array still flags it as a folder, so the chevron shows
- Forgetting aria-label — the tree gets no accessible name and screen readers announce just 'tree'
- Calling onSelect to navigate without de-bouncing arrow-key focus changes — focus moves on arrows but does NOT call onSelect; only Enter/Space/click selects, so navigation should hang off onSelect, not focused state
- Expecting row-click to toggle expand — per WAI-ARIA tree pattern the row click only selects; toggling is the chevron button (or ArrowRight/Left, or Enter/Space when the row is focused). Common surprise after coming from VS Code-style trees
- Passing `selected` pointing at a node inside a collapsed branch — the tree falls back to the first visible node for tab focus, so the consumer can't rely on tabIndex to land on the selected target until it's revealed via expanded
Accessibility
Root: role='tree' with aria-label. Each node: role='treeitem' with aria-level, aria-expanded (folders only), aria-selected, tabIndex=0 only on the active visible node (roving tabindex). Children container: role='group'. Click semantics: row click selects only; the chevron is a separate decorative button that toggles. Keyboard: ArrowDown/Up move through visible non-disabled nodes (disabled nodes are skipped); ArrowRight expands a closed folder or moves to first child; ArrowLeft collapses an open folder or moves to parent; Home/End jump to first/last visible; Enter/Space activate (toggle on folders, select on all).
Related components
Token budget: 2000
Verified against @hex-core/components@1.12.0