The file tree in your editor's sidebar looks like a solved problem you would never build yourself. But a tree view is one of those components that teaches you three separate lessons at once — recursion, keyboard focus management, and accessibility — so I built one in vanilla JavaScript, no library. It is about 110 lines, and every one of them earns its place. Here is how it works.
A tree is a recursive data structure wearing a recursive UI
The data is a nested list. A file node has a name and a type; a folder additionally carries a children array and an open flag. Those children are the same shape, which is what lets it nest forever.
const TREE = [
{ name:"src", type:"folder", open:true, children:[
{ name:"components", type:"folder", children:[
{ name:"Button.jsx", type:"file" },
{ name:"TreeView.jsx", type:"file" },
]},
{ name:"index.js", type:"file" },
]},
{ name:"package.json", type:"file" },
];
The renderer mirrors that shape exactly. buildNode draws one node, and if it is a folder it appends buildNode(child, level + 1) for each child inside a role="group":
function buildNode(node, level){
const item = el("div", { role:"treeitem", "aria-level":level });
item.appendChild(buildRow(node, level)); // chevron + icon + label
if (node.type === "folder"){
item.setAttribute("aria-expanded", !!node.open);
const group = el("div", { role:"group", class:"tv-group" });
node.children.forEach(c => group.appendChild(buildNode(c, level + 1))); // ↻ recurse
item.appendChild(group);
}
return item;
}
The function calling itself is the whole trick. A folder five levels deep is rendered by the identical code as a top-level one. I build the entire tree up front — even closed folders — and just hide them with CSS, so expand and collapse are instant and stateful.
Expand/collapse is one boolean shown three ways
Opening a folder does not touch the DOM structure. You flip one flag and reflect it in three places: the CSS class that shows the group, the aria-expanded attribute for screen readers, and the chevron rotation for the eye.
function setOpen(item, open){
item.setAttribute("aria-expanded", open);
item.querySelector(":scope > .tv-group").classList.toggle("open", open);
item.querySelector(":scope > .tv-row .tv-chev").classList.toggle("open", open);
item._node.open = open;
}
The classic bug is animating the arrow but forgetting the attribute — leaving screen-reader users hearing "collapsed" on a folder that is visibly open.
The visible-nodes list is the backbone
Keyboard navigation does not care about the tree's shape; it cares about the flat list of rows the user can actually see right now. A node is visible only if every folder above it is open, so I walk up from each item and drop it the moment I hit a collapsed group:
function visibleItems(){
return allItems().filter(item => {
for (let g = item.parentElement; g && g !== tree; g = g.parentElement)
if (g.classList.contains("tv-group") && !g.classList.contains("open"))
return false; // an ancestor is collapsed → hidden
return true;
});
}
That collapses the two-dimensional tree into a one-dimensional list, so arrow keys become "previous / next in this list" and never have to reason about depth.
Roving tabindex: the tree is ONE tab stop
A tree with 50 files must not be 50 Tab stops. So exactly one item holds tabindex=0 and every other holds -1. Moving focus is a three-step move I funnel through one helper:
function focusItem(item){
if (!item) return;
allItems().forEach(it => it.tabIndex = -1); // everyone out of tab order
item.tabIndex = 0; // …except the current one
item.focus();
current = item;
}
Tab now enters and leaves the whole tree in one hop; the arrow keys do the moving inside. -1 is important — it keeps the item programmatically focusable while removing it from the sequential Tab order, which is exactly what the roving model needs. This same pattern drives menus, toolbars and radio groups.
The keyboard model itself is the standard WAI-ARIA tree contract: Up/Down step the visible list, Right opens a closed folder or dives to the first child, Left closes an open folder or jumps to the parent, Home/End go to the ends, and typing a letter jumps to the next matching node.
What it teaches
The roles form a contract assistive tech understands: tree on the container, treeitem on every node, group on each folder's children, plus aria-level, aria-expanded and aria-selected. One gotcha worth knowing — a treeitem's accessible name is normally computed from its text, but that would make a folder announce its entire subtree, so give each node an explicit aria-label of just its own name.
Recursion for the render, a flat visible list for navigation, and a roving tabindex for focus — get those three ideas and every tree you ever meet stops being mysterious.
Expand a folder, then click the tree and arrow around — try typing a letter to jump:
https://dev48v.infy.uk/design/day35-tree-view.html
Top comments (0)