A tree view is a compact way to navigate hierarchy: application sections, nested settings, page layers, or a component outline. The hard part is not indentation. It is keeping expansion, selection, focus, keyboard movement, and asynchronous data aligned as the tree grows.
In this guide, we will build a Vue 3 tree view that uses stable node IDs, exposes the expected WAI-ARIA semantics, and supports component-layer workflows. The final component uses one tab stop, arrow-key navigation within the tree, separate focus and selection states, and an extension point for lazy loading.
What we are building: a single-select tree for an application editor. Selecting a node can update a canvas or inspector, while expanding a branch reveals its children without changing the current selection.
Table of contents
- Before you start
- 1. Model your hierarchy with stable IDs
- 2. Build a visible-row projection from the nested tree
- 3. Render the tree with correct roles and separate states
- 4. Add lazy loading without breaking the tree contract
- 5. Implement the keyboard contract before adding extras
- 6. Test semantics, state synchronization, and scale
- 7. Apply the pattern to an inspectable component tree
- FAQ
- Build an editor tree people can actually operate
Before you start
You need Vue 3, TypeScript support, and a small nested data source. We will use the Composition API and keep the source tree owned by the parent, so the tree component emits intent instead of quietly mutating application state.
Use a tree view only when people need to understand and move through parent-child relationships. A short set of unrelated navigation links, filters, or actions is usually better as a list, menu, tabs, or disclosure controls.
For an accessible vertical tree, make these product decisions before writing code:
- Selection model: This example is single-select and does not make selection follow focus. Arrow keys move focus only. Enter selects.
- Expansion model: A parent opens or closes with the disclosure control, or with the Right and Left Arrow keys.
- Activation: Selecting a component layer synchronizes an inspector or canvas. Selecting a navigation node may instead change the current application view.
- Loading: A branch that is not yet loaded needs a busy state and reliable structural metadata.
1. Model your hierarchy with stable IDs
Action: Define nodes with IDs that remain stable across label edits, sorting, drag and drop, and server refreshes.
Do not use a displayed label, array index, or current tree path as an ID. A node called “Header” might be renamed, and two independent cards can each contain a “Button.” A stable ID lets us preserve selection and expansion state even when the visible label changes.
// tree-types.ts
export type AppTreeNode = {
id: string;
label: string;
children?: AppTreeNode[];
hasChildren?: boolean;
lazy?: boolean;
loading?: boolean;
disabled?: boolean;
};
export const initialLayers: AppTreeNode[] = [
{
id: 'page:landing',
label: 'Landing page',
children: [
{
id: 'section:hero',
label: 'Hero',
children: [
{ id: 'text:hero-title', label: 'Headline' },
{ id: 'card:feature', label: 'Feature card' },
],
},
{
id: 'section:remote-components',
label: 'Remote components',
hasChildren: true,
lazy: true,
},
],
},
];
Expected result: Every visible layer has an ID that can safely appear in selectedId, expandedIds, tests, analytics events, and API payloads.
For large or frequently edited trees, normalize the store: keep byId records and a childIds array per parent. Render a nested shape when needed, but make moves, inserts, and server patches against IDs. This avoids cloning an entire deep object graph for every operation.

2. Build a visible-row projection from the nested tree
Action: Traverse the nested data recursively, then render only the rows that are currently visible.
A recursive data walk gives every visible row its depth, parent ID, sibling position, and sibling count. Those values help the UI indent correctly and allow assistive technologies to understand a dynamically loaded or virtualized hierarchy.
// useVisibleTree.ts
import { computed, type Ref } from 'vue';
import type { AppTreeNode } from './tree-types';
export type VisibleRow = {
node: AppTreeNode;
parentId: string | null;
level: number;
posInSet: number;
setSize: number;
};
export function useVisibleTree(
items: Ref<AppTreeNode[]>,
expandedIds: Ref<Set<string>>,
) {
const rows = computed<VisibleRow[]>(() => {
const result: VisibleRow[] = [];
function visit(nodes: AppTreeNode[], parentId: string | null, level: number) {
nodes.forEach((node, index) => {
result.push({
node,
parentId,
level,
posInSet: index + 1,
setSize: nodes.length,
});
if (node.children?.length && expandedIds.value.has(node.id)) {
visit(node.children, node.id, level + 1);
}
});
}
visit(items.value, null, 1);
return result;
});
return { rows };
}
This technique is still recursive where hierarchy matters, but the rendered list is a flat projection of visible nodes. That makes Up and Down Arrow movement a simple previous or next row operation, and it prepares the component for windowing later.
Troubleshooting: Do not place Set mutations directly inside a computed getter. Replace the set when changing expansion so Vue receives a new reactive value.
expandedIds.value = new Set([...expandedIds.value, nodeId]);
3. Render the tree with correct roles and separate states
Action: Give the container role="tree", each row role="treeitem", and give parent rows aria-expanded. Keep focus and selection as separate state variables.
A focused row answers “where am I navigating?” A selected row answers “which component or application area is active?” In an editor, focus may move through layers while the canvas remains tied to the selected component. Show these states with clearly different visual treatments, such as an outline for focus and a filled background for selection.
<!-- AppTreeView.vue -->
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useVisibleTree, type VisibleRow } from './useVisibleTree';
import type { AppTreeNode } from './tree-types';
const props = defineProps<{
items: AppTreeNode[];
selectedId: string | null;
label: string;
}>();
const emit = defineEmits<{
'update:selectedId': [id: string];
'load-children': [id: string];
}>();
const itemsRef = computed(() => props.items);
const expandedIds = ref(new Set<string>(['page:landing']));
const focusedId = ref(props.selectedId ?? props.items[0]?.id ?? '');
const rowElements = new Map<string, HTMLElement>();
const { rows } = useVisibleTree(itemsRef, expandedIds);
const focusedRow = computed(() =>
rows.value.find((row) => row.node.id === focusedId.value) ?? rows.value[0],
);
watch(
() => props.selectedId,
(id) => {
if (id) focusedId.value = id;
},
);
function isExpandable(node: AppTreeNode) {
return Boolean(node.children?.length || node.hasChildren || node.lazy);
}
function isExpanded(id: string) {
return expandedIds.value.has(id);
}
function expand(node: AppTreeNode) {
if (!isExpandable(node)) return;
if (node.lazy && !node.loading && !node.children) emit('load-children', node.id);
expandedIds.value = new Set([...expandedIds.value, node.id]);
}
function collapse(id: string) {
const next = new Set(expandedIds.value);
next.delete(id);
expandedIds.value = next;
}
function moveFocus(id: string) {
focusedId.value = id;
nextTick(() => rowElements.get(id)?.focus());
}
function select(row: VisibleRow) {
if (!row.node.disabled) emit('update:selectedId', row.node.id);
}
function onKeydown(event: KeyboardEvent, row: VisibleRow) {
const index = rows.value.findIndex((item) => item.node.id === row.node.id);
const firstChild = rows.value.find((item) => item.parentId === row.node.id);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (rows.value[index + 1]) moveFocus(rows.value[index + 1].node.id);
break;
case 'ArrowUp':
event.preventDefault();
if (rows.value[index - 1]) moveFocus(rows.value[index - 1].node.id);
break;
case 'Home':
event.preventDefault();
if (rows.value[0]) moveFocus(rows.value[0].node.id);
break;
case 'End':
event.preventDefault();
if (rows.value.at(-1)) moveFocus(rows.value.at(-1)!.node.id);
break;
case 'ArrowRight':
event.preventDefault();
if (isExpandable(row.node) && !isExpanded(row.node.id)) expand(row.node);
else if (firstChild) moveFocus(firstChild.node.id);
break;
case 'ArrowLeft':
event.preventDefault();
if (isExpanded(row.node.id)) collapse(row.node.id);
else if (row.parentId) moveFocus(row.parentId);
break;
case 'Enter':
case ' ':
event.preventDefault();
select(row);
break;
}
}
</script>
<template>
<ul class="app-tree" role="tree" :aria-label="label">
<li
v-for="row in rows"
:key="row.node.id"
:ref="(element) => {
if (element) rowElements.set(row.node.id, element as HTMLElement);
else rowElements.delete(row.node.id);
}"
role="treeitem"
:tabindex="focusedRow?.node.id === row.node.id ? 0 : -1"
:aria-level="row.level"
:aria-posinset="row.posInSet"
:aria-setsize="row.setSize"
:aria-selected="selectedId === row.node.id"
:aria-expanded="isExpandable(row.node) ? isExpanded(row.node.id) : undefined"
:aria-busy="row.node.loading || undefined"
:class="{
'is-focused': focusedId === row.node.id,
'is-selected': selectedId === row.node.id,
}"
:style="{ paddingInlineStart: `${(row.level - 1) * 1.25 + 0.5}rem` }"
@focus="focusedId = row.node.id"
@keydown="onKeydown($event, row)"
@click="select(row)"
>
<button
v-if="isExpandable(row.node)"
type="button"
class="tree-toggle"
:aria-label="`${isExpanded(row.node.id) ? 'Collapse' : 'Expand'} ${row.node.label}`"
tabindex="-1"
@click.stop="isExpanded(row.node.id) ? collapse(row.node.id) : expand(row.node)"
>
<span aria-hidden="true">{{ isExpanded(row.node.id) ? '▾' : '▸' }}</span>
</button>
<span>{{ row.node.label }}</span>
</li>
</ul>
</template>
Expected result: Tab enters the tree once. The active row is the only tree item with tabindex="0"; all other rows have tabindex="-1". This is the roving tabindex pattern.
Troubleshooting: Do not add an aria-expanded attribute to a leaf node. A leaf has nothing to reveal, and announcing it as expandable creates misleading feedback.
4. Add lazy loading without breaking the tree contract
Action: Treat lazy loading as a state transition in the parent store, not as hidden component mutation.
When a user expands a remote branch, immediately mark it loading, fetch the children, then replace the node with a new version containing the returned children. Keep the branch open when the response arrives. If it fails, expose a retry action that is reachable by keyboard.
// layers-store.ts
import { ref } from 'vue';
import type { AppTreeNode } from './tree-types';
export const layers = ref<AppTreeNode[]>(initialLayers);
export const selectedLayerId = ref<string | null>('text:hero-title');
export async function loadChildren(id: string) {
patchNode(layers.value, id, { loading: true });
try {
const response = await fetch(`/api/layers/${encodeURIComponent(id)}/children`);
if (!response.ok) throw new Error('Could not load children');
const children = (await response.json()) as AppTreeNode[];
patchNode(layers.value, id, {
children,
loading: false,
lazy: false,
hasChildren: children.length > 0,
});
} catch (error) {
patchNode(layers.value, id, { loading: false });
// Store an error state or a retry action appropriate to your application.
}
}
function patchNode(nodes: AppTreeNode[], id: string, patch: Partial<AppTreeNode>): boolean {
for (const node of nodes) {
if (node.id === id) {
Object.assign(node, patch);
return true;
}
if (node.children && patchNode(node.children, id, patch)) return true;
}
return false;
}
When the complete sibling set is not in the DOM because branches are lazy-loaded or virtualized, retain aria-level, aria-posinset, and aria-setsize on each visible row. They communicate structural context that a fully nested DOM might otherwise provide.
If your tree is a client-side route navigator, activation needs an additional decision: move focus to the new page heading when people should begin reading new content, or keep focus in the tree and mark the active destination appropriately when rapid navigation among destinations is the primary task.
5. Implement the keyboard contract before adding extras
Action: Test every key in the base interaction model before implementing drag and drop, checkboxes, context menus, or type-ahead.
The expected behavior for a vertical single-select tree is straightforward:
- Down Arrow moves focus to the next visible node.
- Up Arrow moves focus to the previous visible node.
- Right Arrow expands a closed parent. If it is already open, it moves focus to the first child.
- Left Arrow collapses an open parent. On a closed node or leaf, it moves focus to the parent.
- Home and End move to the first and last visible nodes.
- Enter performs the primary action. In this implementation, it selects the focused node.
- Space should not accidentally scroll the page. Use it only when it has a clear tree action, such as selection in this example or toggling selection in a multi-select model.
Do not make every control inside a tree row a separate tab stop. Give the tree one entry point and use arrow keys for movement inside it. If a row needs secondary controls such as visibility, lock, or context actions, decide whether they should be exposed through an explicit action mode, a context menu, or a predictable shortcut. Test that design with keyboard and screen-reader users.

Add type-ahead for long lists
Type-ahead is useful when the tree has many visible siblings. Keep a short buffer, reset it after a brief pause, and begin the next search after the currently focused row so repeated typing cycles through similarly named layers.
let typeahead = '';
let resetTimer: number | undefined;
function onTypeahead(event: KeyboardEvent) {
if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return;
typeahead += event.key.toLocaleLowerCase();
window.clearTimeout(resetTimer);
resetTimer = window.setTimeout(() => { typeahead = ''; }, 500);
const start = rows.value.findIndex((row) => row.node.id === focusedId.value);
const candidates = [...rows.value.slice(start + 1), ...rows.value.slice(0, start + 1)];
const match = candidates.find((row) =>
row.node.label.toLocaleLowerCase().startsWith(typeahead),
);
if (match) moveFocus(match.node.id);
}
Troubleshooting: Avoid character shortcuts that trigger while a user is typing into an input elsewhere in the editor. Attach type-ahead only while focus is inside the tree.
6. Test semantics, state synchronization, and scale
Action: Build verification into the component before you introduce complex interactions.
Start with these manual checks:
- Press Tab from the preceding control. Focus enters one tree item, not every row and action button.
- Use every arrow-key rule on a closed parent, open parent, child, root leaf, and final visible row.
- Verify that focus remains visibly distinct from selection while arrowing through the tree.
- Expand a lazy node. Confirm that a loading state is announced or otherwise exposed, then verify that the loaded children receive correct depth and sibling metadata.
- Use a screen reader to verify the tree label, node name, selected state, expanded state, and hierarchy information.
- Confirm that clicking a canvas component updates the selected tree row, and that selecting a tree row updates the canvas or inspector without losing keyboard focus unexpectedly.
For automated coverage, test behavior rather than implementation details. Assert the focused element, aria-expanded, aria-selected, and emitted events. Add a test that loads a branch and then uses Right Arrow to enter its first child after the asynchronous update.
For a few hundred visible rows, the projection above is often sufficient. As the visible count grows, cache indexes, avoid deep clones during keyboard movement, and virtualize only after you can preserve focus and ARIA positional metadata. Virtualization is not a substitute for a clean state model.
7. Apply the pattern to an inspectable component tree
Action: Connect your selected ID to the rest of the editor, not just to a highlighted row.
A component tree becomes much more useful when it is a shared interface between the canvas, inspector, source view, and state store. Selecting a button on the canvas should select its layer. Selecting a nested layer should let the inspector show editable properties. Reordering or moving a node should be an explicit store operation that respects rules such as locked containers or valid child types.
DOM Studio demonstrates this workflow with a Tree View component that supports live selection, expansion, lazy-loading events, and layer-style examples. Its component playground also shows how a layer list and live stage can stay synchronized as people select a nested component. We can use that pattern to keep source hints, inspector metadata, examples, and editable application UI close together.
For a product editor, consider these guardrails:
- Keep
selectedId,focusedId, andexpandedIdsseparate. They describe different user intent. - Make drag and drop an optional enhancement, not the only way to organize hierarchy.
- Validate moves in the store, including locked nodes and allowed parent-child relationships.
- Keep node labels concise, unique enough to scan, and paired with icons only when the icons add real meaning.
- Persist IDs and hierarchy independently from transient UI state when different collaborators can open the same document.
FAQ
Should a Vue tree view use nested DOM or a flat list?
Both can work. Nested DOM naturally mirrors the hierarchy. A flat visible-row projection makes keyboard navigation, filtering, and virtualization easier. Whichever approach we choose, the tree must expose the correct roles, expansion state, focus behavior, and hierarchy information.
Should selection follow focus?
Only when moving focus should also immediately change the active object or view. For component layers, separate focus and selection is usually safer because people can inspect nearby items without changing the canvas selection. Make the two visual states unmistakably different.
When should I use a treegrid instead?
Use a treegrid when each hierarchical row needs several independently meaningful columns, such as name, owner, status, and modified date. A component outline or settings navigator with one primary label is generally a tree view.
Can I add checkboxes to a tree?
Yes, but define whether checking is a separate state from selecting. Avoid combining aria-selected and aria-checked unless those states have clearly different meanings, clear visuals, and separate interactions. In many editors, a dedicated visibility or enabled control is easier to understand than a multi-select tree.
Build an editor tree people can actually operate
A strong Vue tree view has a predictable core: stable IDs, one source of truth, a visible-row model, explicit expansion, and the keyboard interactions people expect. Add lazy loading, drag and drop, and inspector synchronization only after that core is reliable.
If you are building editable application interfaces, explore DOM Studio’s Vue and Web Component UI system and its Tree View implementation. It provides an application-focused starting point for layers, nested navigation, and component metadata workflows without treating hierarchy as an afterthought.
