Skip to main content
Back to Blog
Tutorials
3 min read
January 7, 2025

How to Build a Tree View Component With Keyboard Navigation in React

Build a recursive tree view component with expand/collapse, keyboard arrow key navigation, multi-select, and ARIA tree roles.

Ryel Banfield

Founder & Lead Developer

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.

tree viewkeyboard navigationReactaccessibilitycomponenttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles