Tree views are common in file explorers, menus, and configuration panels. Here is how to build one that is fully accessible.
Tree Data Types
export interface TreeNode {
id: string;
label: string;
children?: TreeNode[];
icon?: React.ReactNode;
}
export interface TreeState {
expanded: Set<string>;
selected: Set<string>;
focused: string | null;
}
Tree Context and State
"use client";
import {
createContext,
useContext,
useReducer,
useCallback,
useRef,
type ReactNode,
} from "react";
type TreeAction =
| { type: "TOGGLE_EXPAND"; id: string }
| { type: "SELECT"; id: string; multi: boolean }
| { type: "FOCUS"; id: string };
function treeReducer(state: TreeState, action: TreeAction): TreeState {
switch (action.type) {
case "TOGGLE_EXPAND": {
const expanded = new Set(state.expanded);
if (expanded.has(action.id)) {
expanded.delete(action.id);
} else {
expanded.add(action.id);
}
return { ...state, expanded };
}
case "SELECT": {
if (action.multi) {
const selected = new Set(state.selected);
if (selected.has(action.id)) {
selected.delete(action.id);
} else {
selected.add(action.id);
}
return { ...state, selected };
}
return { ...state, selected: new Set([action.id]) };
}
case "FOCUS":
return { ...state, focused: action.id };
default:
return state;
}
}
interface TreeContextType {
state: TreeState;
dispatch: React.Dispatch<TreeAction>;
flatNodes: TreeNode[];
multiSelect: boolean;
}
const TreeContext = createContext<TreeContextType | null>(null);
function useTree() {
const ctx = useContext(TreeContext);
if (!ctx) throw new Error("useTree must be used within TreeProvider");
return ctx;
}
function flattenTree(nodes: TreeNode[], expanded: Set<string>): TreeNode[] {
const result: TreeNode[] = [];
for (const node of nodes) {
result.push(node);
if (node.children && expanded.has(node.id)) {
result.push(...flattenTree(node.children, expanded));
}
}
return result;
}
Tree Components
interface TreeViewProps {
data: TreeNode[];
multiSelect?: boolean;
onSelect?: (selectedIds: string[]) => void;
className?: string;
}
export function TreeView({
data,
multiSelect = false,
onSelect,
className,
}: TreeViewProps) {
const [state, dispatch] = useReducer(treeReducer, {
expanded: new Set<string>(),
selected: new Set<string>(),
focused: null,
});
const flatNodes = flattenTree(data, state.expanded);
const handleSelect = useCallback(
(id: string, multi: boolean) => {
dispatch({ type: "SELECT", id, multi });
const next = new Set(state.selected);
if (multi) {
next.has(id) ? next.delete(id) : next.add(id);
} else {
next.clear();
next.add(id);
}
onSelect?.(Array.from(next));
},
[state.selected, onSelect],
);
return (
<TreeContext.Provider value={{ state, dispatch, flatNodes, multiSelect }}>
<ul
role="tree"
aria-multiselectable={multiSelect}
className={className}
>
{data.map((node) => (
<TreeItem key={node.id} node={node} level={1} />
))}
</ul>
</TreeContext.Provider>
);
}
interface TreeItemProps {
node: TreeNode;
level: number;
}
function TreeItem({ node, level }: TreeItemProps) {
const { state, dispatch, flatNodes, multiSelect } = useTree();
const ref = useRef<HTMLLIElement>(null);
const hasChildren = node.children && node.children.length > 0;
const isExpanded = state.expanded.has(node.id);
const isSelected = state.selected.has(node.id);
const isFocused = state.focused === node.id;
function handleKeyDown(e: React.KeyboardEvent) {
const currentIndex = flatNodes.findIndex((n) => n.id === node.id);
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
const nextNode = flatNodes[currentIndex + 1];
if (nextNode) dispatch({ type: "FOCUS", id: nextNode.id });
break;
}
case "ArrowUp": {
e.preventDefault();
const prevNode = flatNodes[currentIndex - 1];
if (prevNode) dispatch({ type: "FOCUS", id: prevNode.id });
break;
}
case "ArrowRight": {
e.preventDefault();
if (hasChildren && !isExpanded) {
dispatch({ type: "TOGGLE_EXPAND", id: node.id });
} else if (hasChildren && isExpanded && node.children) {
dispatch({ type: "FOCUS", id: node.children[0].id });
}
break;
}
case "ArrowLeft": {
e.preventDefault();
if (hasChildren && isExpanded) {
dispatch({ type: "TOGGLE_EXPAND", id: node.id });
}
break;
}
case "Enter":
case " ": {
e.preventDefault();
dispatch({ type: "SELECT", id: node.id, multi: multiSelect && e.ctrlKey });
break;
}
case "Home": {
e.preventDefault();
dispatch({ type: "FOCUS", id: flatNodes[0].id });
break;
}
case "End": {
e.preventDefault();
dispatch({ type: "FOCUS", id: flatNodes[flatNodes.length - 1].id });
break;
}
}
}
return (
<li
ref={ref}
role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
aria-level={level}
tabIndex={isFocused ? 0 : -1}
onKeyDown={handleKeyDown}
onFocus={() => dispatch({ type: "FOCUS", id: node.id })}
onClick={(e) => {
dispatch({
type: "SELECT",
id: node.id,
multi: multiSelect && (e.ctrlKey || e.metaKey),
});
}}
className={`
cursor-pointer select-none outline-none
${isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted"}
${isFocused ? "ring-2 ring-primary ring-inset" : ""}
`}
style={{ paddingLeft: `${(level - 1) * 20 + 8}px` }}
>
<div className="flex items-center gap-1.5 py-1 pr-2">
{hasChildren ? (
<button
tabIndex={-1}
aria-hidden
onClick={(e) => {
e.stopPropagation();
dispatch({ type: "TOGGLE_EXPAND", id: node.id });
}}
className="w-4 h-4 flex items-center justify-center text-xs"
>
{isExpanded ? "βΌ" : "βΆ"}
</button>
) : (
<span className="w-4" />
)}
{node.icon && <span aria-hidden>{node.icon}</span>}
<span className="text-sm">{node.label}</span>
</div>
{hasChildren && isExpanded && (
<ul role="group">
{node.children!.map((child) => (
<TreeItem key={child.id} node={child} level={level + 1} />
))}
</ul>
)}
</li>
);
}
Usage
const fileTree: TreeNode[] = [
{
id: "src",
label: "src",
children: [
{
id: "components",
label: "components",
children: [
{ id: "header", label: "Header.tsx" },
{ id: "footer", label: "Footer.tsx" },
],
},
{ id: "app", label: "App.tsx" },
{ id: "index", label: "index.ts" },
],
},
{ id: "package", label: "package.json" },
{ id: "readme", label: "README.md" },
];
<TreeView
data={fileTree}
multiSelect
onSelect={(ids) => console.log("Selected:", ids)}
className="border rounded-lg p-2 w-72"
/>
Need Custom UI Components?
We build accessible component libraries tailored to your product. Contact us to get started.