Start with a clear interaction model

A tree view presents nested choices without showing every level at once. A folder browser is a familiar example: a branch can be expanded or collapsed, while an item can be selected. Those are different states. Expanding a branch reveals its children; selecting an item says which item the user has chosen. Keyboard focus is a third state: it identifies where the next keyboard action will occur.

The example below runs in a browser with support for modern JavaScript and DOM APIs. Paste the snippets, in order, into the browser console on an otherwise empty page. It creates its own sample data and elements; no framework, build step, or existing HTML is required. For a production interface, place the code in your application and adapt the data and selection action.

Render nested items and preserve their relationships

Represent each item with a label and an optional children array. The renderer creates a treeitem for each entry and puts its children inside a group belonging to that item. Initially, child groups are hidden. The item’s aria-expanded value describes that state, while aria-selected describes selection. Only the first item starts in the tab order; keyboard navigation will move that tab stop.

const data = [{ label: 'Projects', children: [{ label: 'Website' }, { label: 'Documentation' }] }, { label: 'Archive', children: [{ label: 'Previous project' }] }]; const tree = document.createElement('div'); tree.setAttribute('role', 'tree'); tree.setAttribute('aria-label', 'Projects'); document.body.append(tree); let selected = null; function renderItem(node) { const item = document.createElement('div'); item.setAttribute('role', 'treeitem'); item.setAttribute('aria-label', node.label); item.setAttribute('aria-selected', 'false'); item.tabIndex = -1; item.textContent = node.label; if (node.children?.length) { const group = document.createElement('div'); group.setAttribute('role', 'group'); node.children.forEach(child => group.append(renderItem(child))); group.hidden = true; item.setAttribute('aria-expanded', 'false'); item.append(group); } return item; } data.forEach(node => tree.append(renderItem(node))); tree.firstElementChild.tabIndex = 0;

This deliberately small renderer does not rerender the tree when an item changes. Keeping the existing elements makes it easier to retain focus and selection. The plain text labels are also intentional: using textContent rather than interpreting labels as markup is a safer default when labels come from data.

Add selection, expansion, and keyboard movement

These functions operate on the elements already in the tree. Arrow Up and Arrow Down move among visible items. Arrow Right opens a closed branch or enters an open one; Arrow Left closes an open branch or moves to its parent. Home and End move to the first and last visible items. Enter and Space select the focused item.

function focusItem(item) { tree.querySelectorAll('[role="treeitem"]').forEach(el => { el.tabIndex = -1; }); item.tabIndex = 0; item.focus(); } function selectItem(item) { if (selected) selected.setAttribute('aria-selected', 'false'); selected = item; item.setAttribute('aria-selected', 'true'); } function setExpanded(item, expanded) { const group = item.querySelector(':scope > [role="group"]'); if (!group) return; group.hidden = !expanded; item.setAttribute('aria-expanded', String(expanded)); } function visibleItems() { return [...tree.querySelectorAll('[role="treeitem"]')] .filter(item => !item.closest('[hidden]')); } tree.addEventListener('keydown', event => { const item = event.target.closest('[role="treeitem"]'); if (!item || !tree.contains(item)) return; const items = visibleItems(); const index = items.indexOf(item); const group = item.querySelector(':scope > [role="group"]'); const parent = item.parentElement.closest('[role="treeitem"]'); let next = null; switch (event.key) { case 'ArrowDown': next = items[index + 1]; break; case 'ArrowUp': next = items[index - 1]; break; case 'Home': next = items[0]; break; case 'End': next = items[items.length - 1]; break; case 'ArrowRight': if (group?.hidden) setExpanded(item, true); else if (group) next = group.firstElementChild; break; case 'ArrowLeft': if (group && !group.hidden) setExpanded(item, false); else next = parent; break; case 'Enter': case ' ': selectItem(item); break; default: return; } event.preventDefault(); if (next) focusItem(next); });

Make pointer behavior understandable

For this compact example, clicking an item focuses and selects it; clicking a branch also toggles its children. The event-target check prevents a click on a nested child from toggling every ancestor as the event bubbles upward. In a larger interface, consider a visibly separate disclosure control so pointer users can expand a branch without changing selection.

tree.addEventListener('click', event => { const item = event.target.closest('[role="treeitem"]'); if (!item || !tree.contains(item)) return; focusItem(item); selectItem(item); const group = item.querySelector(':scope > [role="group"]'); if (group) setExpanded(item, group.hidden); });

The example has no selection callback because it has no surrounding application. Add one inside selectItem if choosing an item should update another panel or application state. Avoid treating expansion as a selection event: users may be browsing the hierarchy without choosing anything.

Check behavior before adding complexity

Tab into the tree, open Projects with Arrow Right, move to Website with Arrow Down, and select it with Enter. Arrow Left from Website should return to Projects; another Arrow Left should collapse it. Try Home and End after opening a branch to confirm that hidden descendants are skipped. Then repeat the main actions with a pointer and check that only one item reports aria-selected as true.

A tree view also needs a visible focus indicator and a clear visual distinction for selected and expanded states. Add styling for those states in the host site rather than relying on attributes alone. If you later add item removal, data updates, or lazy loading, decide where focus should go when the focused item disappears and keep aria-expanded synchronized with what is actually visible.

Back to category ↗