TreeView
A compact single-select tree for project files and settings categories. Expansion, selection, and keyboard focus are independent. useTree derives an ordered list of visible nodes; TreeView renders that list with TreeItem. The data and interaction state remain independent of mounted rows.
Anatomy
| Element | Part | Selector |
|---|---|---|
<div> | root | [data-scope="tree-view"][data-part="root"]LMBCopy |
Anatomy
| Element | Part | Selector |
|---|---|---|
<div> | root | [data-scope="tree-item"][data-part="root"]LMBCopy |
<div> | row | [data-scope="tree-item"][data-part="row"]LMBCopy |
<span> | disclosure | [data-scope="tree-item"][data-part="disclosure"]LMBCopy |
<svg> | arrowconditional | [data-scope="tree-item"][data-part="arrow"]LMBCopy |
<path> | arrow-path | [data-scope="tree-item"][data-part="arrow-path"]LMBCopy |
<span> | leadingconditional | [data-scope="tree-item"][data-part="leading"]LMBCopy |
<span> | label | [data-scope="tree-item"][data-part="label"]LMBCopy |
<span> | countconditional | [data-scope="tree-item"][data-part="count"]LMBCopy |
<span> | trailingconditional | [data-scope="tree-item"][data-part="trailing"]LMBCopy |
Props
| Name | Description | Default | Control |
|---|---|---|---|
shapeOptional | Corner style; inherits the shared shape default. M3Shape | undefined | - | - |
sizeOptional | Row size; inherits the nearest row size or defaults to medium. RowSize | undefined | - | - |
itemsRequired | - readonly TreeNode[] | - | - |
selectWhenFocusedOptional | - boolean | undefined | false | |
dirOptional | - ltrrtl | - | - |
modelValueOptional | - string | undefined | - | - |
expandedOptional | - string[] | undefined | [] | - |
Events4
| Name | Type | Description |
|---|---|---|
update:modelValue | [value: string | undefined] | - |
update:expanded | [value: string[]] | - |
activate | [item: TreeNode] | - |
focusRequest | [value: string] | - |
Slots4
| Name | Parameters | Description |
|---|---|---|
default | - | - |
label | - | - |
leading | - | - |
trailing | - | - |
Props
| Name | Description | Default |
|---|---|---|
shapeOptional | Corner style; inherits the shared shape default. M3Shape | undefined | - |
itemRequired | - TreeNode | - |
sizeOptional | - RowSize | undefined | - |
Slots3
| Name | Parameters | Description |
|---|---|---|
label | - | - |
leading | - | - |
trailing | - | - |
Styling10
| Token | Description |
|---|---|
--md-tree-row-heightSizeFallback 48 dp at density 0; 32 dp at density -4 | Minimum row height. |
--md-tree-indentSpacingFallback 16 dp | Indentation for each nested level. |
--md-tree-padding-inlineSpacingFallback 8 dp | Padding at each row edge. |
--md-tree-gapSpacingFallback 6 dp | Space between row contents. |
--md-tree-shapeShapeFallback var(--md-shape-compact) | Row corner shape. |
--md-tree-container-colorColorFallback var(--md-color-container, transparent) | Tree background. |
--md-tree-label-colorColorFallback var(--md-color-on-container, var(--md-sys-color-on-surface)) | Unselected row content color. |
--md-tree-selected-container-colorColorFallback var(--md-sys-color-secondary-container) | Selected row background. |
--md-tree-selected-label-colorColorFallback var(--md-sys-color-on-secondary-container) | Selected row content and state layer color. |
--md-tree-icon-sizeSizeFallback Typography, density and icon scale (default reference: 18 dp) | Leading icon and disclosure size. |
Row sizes
Set size on TreeView for its rows. Custom row rendering can set size on individual TreeItem components; flattened rows do not inherit the size of their logical parent node.
See Row sizes for the scale, shared rowSize default and density behavior.
Examples
Project files and actions
Use the leading, label, and trailing slots to customize every row. They receive { item, selected, expanded, focused, disabled, branch, busy }. Keep labels and leading content noninteractive. Place buttons in trailing, give them accessible names, bind disabled, and use :tabindex="focused ? 0 : -1" so actions on other rows do not enter the tab order. Actions do not select or expand the row. Counts are visual; include their meaning in label when needed for accessibility.
Settings categories
Set selectable: false for grouping categories. Clicking or pressing Enter on a category toggles it without changing the selected setting. The default slot receives { tree } and can render tree.visibleItems.value explicitly. Each TreeItem renders exactly one row; it does not recursively mount children. Use node values as keys and preserve the visible list's order.
Asynchronous children
Set hasChildren: true when a branch can be expanded before its children are loaded. Set busy: true while loading to expose aria-busy. Populate children when data arrives. Nonempty children also establish a branch; otherwise hasChildren defaults to false. For an empty result, the caller can retain a directory as an expandable branch or set hasChildren: false to make it a leaf.
The application owns requests, caches, cancellation, and errors. Observe the expanded model to start loading; use slots for progress and retry controls. The example cancels pending requests on collapse/unmount and retains successfully loaded children for subsequent expansion. It also restores row focus before replacing a retry button with a progress indicator. Production loaders should discard obsolete responses when their source changes. The tree never starts requests or creates a second copy of loaded children.
External virtualization
<script setup lang="ts">
import { computed } from 'vue';
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/vue-virtual';
import { TreeItem } from '@/components/TreeView';
import type { UseTreeReturn } from '@/composables/useTree';
const props = defineProps<{ tree: UseTreeReturn; scrollElement: HTMLElement | null }>();
const virtualizer = useVirtualizer(
computed(() => {
const entries = props.tree.visibleItems.value;
const focusedIndex = props.tree.visibleIndexes.value.get(props.tree.focusedValue.value ?? '');
return {
count: entries.length,
getScrollElement: () => props.scrollElement,
estimateSize: () => 32,
getItemKey: (index: number) => entries[index]!.node.value,
overscan: 5,
rangeExtractor: (range: Parameters<typeof defaultRangeExtractor>[0]) => {
const indexes = defaultRangeExtractor(range);
if (focusedIndex !== undefined && !indexes.includes(focusedIndex)) indexes.push(focusedIndex);
return indexes.sort((left, right) => left - right);
},
};
}),
);
function reveal(value: string) {
const index = props.tree.visibleIndexes.value.get(value);
if (index !== undefined) virtualizer.value.scrollToIndex(index, { align: 'auto' });
}
defineExpose({ reveal });
</script>
<template>
<div class="tree-virtual-content" :style="{ height: `${virtualizer.getTotalSize()}px` }" role="presentation">
<TreeItem
v-for="row in virtualizer.getVirtualItems()"
:key="String(row.key)"
:item="tree.visibleItems.value[row.index]!.node"
class="tree-virtual-row"
:style="{ transform: `translateY(${row.start}px)` }"
/>
</div>
</template>
<style scoped>
.tree-virtual-content {
position: relative;
inline-size: 100%;
}
.tree-virtual-row {
position: absolute;
inset-block-start: 0;
inset-inline: 0;
block-size: 32px;
--md-tree-row-height: 32px;
}
</style>This example uses the application's TanStack Virtual instance to render 10,000 files. TanStack is only a documentation/example dependency; the component library neither imports it nor owns the scroll container.
The integration has three responsibilities:
- Render a window of
tree.visibleItems.value, keyed byentry.node.value.tree.visibleIndexes.valuemaps values to row indexes. - Handle
focusRequest(value)by scrolling the corresponding index into view. This synchronous notification runs before the library focuses the row; a missing row is focused after Vue's next render. A headless consumer usesonFocusRequestinstead. Keep the focused index in TanStack'srangeExtractorso manual scrolling does not unmount the active row or its trailing controls. - Own row measurements and the scrolling viewport. The example uses matching 32 px estimates and row heights; use the virtualizer's measurement APIs for variable-height rows. Give the viewport a bounded height, put content padding inside it, and leave fixed toolbars outside it.
The tree's navigation and type-ahead use the entire visible model, including rows outside the viewport. Unloaded children are not searchable until the application supplies them. Tree state survives row unmounts; application state must also live outside row components if it needs to survive collapse or virtualization.
Data and state
items is a recursive array of TreeNode: value (globally unique string), label (accessible name and type-ahead text), optional children, hasChildren, busy, icon, count, disabled, and selectable. Duplicate values are rejected.
v-modelholds one selected value, orundefined. Hidden selection is retained when a parent closes.v-model:expandedholds expanded branch values. Both models also work without binding for local state.activatereports a row's default action (click or Enter). Space selects without activation.selectWhenFocusedopts into selection following focus; the default isfalse.- The exposed
focus(value)method focuses a visible node without opening its ancestors. - Disabled nodes remain reachable by arrow keys for discovery, but cannot activate, select, or toggle. A disabled branch also disables its descendants.
- When a focused node is hidden or removed, focus returns to its nearest visible ancestor, then a neighboring visible node. An empty tree remains focusable. Updates do not steal focus from outside the tree.
Keyboard and accessibility
Give the tree an aria-label or aria-labelledby. The components provide tree and treeitem roles, explicit aria-level, aria-posinset, aria-setsize, expansion and selection state, and a roving tab stop. Hierarchy is declared on flat rows rather than inferred from nested groups. Collapsed descendants are unmounted. These attributes remain based on the data model when only a window of rows is mounted. Keyboard behavior follows the WAI-ARIA tree view pattern.
| Key | Behavior |
|---|---|
| Up / Down | Previous / next visible node, without wrapping |
| Right | Expand a branch, then move to its first child |
| Left | Collapse a branch, or move to its parent |
| Home / End | First / last visible node |
| Enter | Select and activate; toggle a nonselectable category |
| Space | Select without activation |
| Characters | Find a visible label by prefix; repeated characters cycle matches |
* | Expand enabled sibling branches |
| Tab / Shift+Tab | Normal document order, including the focused row's trailing actions |
| F2 | Focus the first enabled trailing action |
| Escape in trailing actions | Return focus to the row |
Left and Right are mirrored in RTL. Pass dir or inherit the document direction. Typing starts a new prefix after 500 ms. Editable controls and nested action key events are not handled as tree navigation.
Headless use
Full type signature
function useTree(options: UseTreeOptions): { value: import("vue").ComputedRef<string | undefined>; expanded: import("vue").ComputedRef<readonly string[]>; focusedValue: import("vue").ComputedRef<string>; visibleItems: import("vue").ComputedRef<TreeEntry[]>; visibleIndexes: import("vue").ComputedRef<Map<string, number>>; entries: import("vue").ComputedRef<Map<string, TreeEntry>>; select: (next: string | undefined) => void; setExpanded: (values: readonly string[]) => void; toggle: (key: string) => void; focus: (key: string) => void; activate: (key: string) => void; getItemAttrs: (key: string) => { role: string; tabindex: number; 'aria-label': string | undefined; 'aria-level': number | undefined; 'aria-posinset': number | undefined; 'aria-setsize': number | undefined; 'aria-expanded': boolean | undefined; 'aria-busy': true | undefined; 'aria-selected': boolean | undefined; 'aria-disabled': true | undefined; ref: (element: unknown) => void; onFocusin: (event: FocusEvent) => void; onKeydown: (event: KeyboardEvent) => void; }; rootAttrs: import("vue").ComputedRef<{ role: string; tabindex: number; ref: (element: unknown) => void; }>; }Source: src/composables/useTree.ts
| Field | Type | Required | Description | Default |
|---|---|---|---|---|
value | string | Yes | - | - |
label | string | Yes | - | - |
children | readonly TreeNode[] | undefined | No | - | - |
hasChildren | boolean | undefined | No | - | - |
busy | boolean | undefined | No | - | - |
disabled | boolean | undefined | No | - | - |
selectable | boolean | undefined | No | - | - |
icon | string | undefined | No | - | - |
count | string | number | undefined | No | - | - |
| Field | Type | Required | Description | Default |
|---|---|---|---|---|
items | MaybeRefOrGetter<readonly TreeNode[]> | Yes | - | - |
value | MaybeRefOrGetter<string | undefined> | No | - | - |
expanded | MaybeRefOrGetter<readonly string[]> | undefined | No | - | - |
onValueChange | ((value: string | undefined) => void) | undefined | No | - | - |
onExpandedChange | ((values: string[]) => void) | undefined | No | - | - |
onActivate | ((node: TreeNode) => void) | undefined | No | - | - |
onFocusRequest | ((value: string) => void) | undefined | No | - | - |
selectWhenFocused | MaybeRefOrGetter<boolean> | undefined | No | - | - |
dir | MaybeRefOrGetter<"ltr" | "rtl" | undefined> | No | - | - |
| Field | Type | Required | Description | Default |
|---|---|---|---|---|
value | import("vue").ComputedRef<string | undefined> | Yes | - | - |
expanded | import("vue").ComputedRef<readonly string[]> | Yes | - | - |
focusedValue | import("vue").ComputedRef<string> | Yes | - | - |
visibleItems | import("vue").ComputedRef<TreeEntry[]> | Yes | - | - |
visibleIndexes | import("vue").ComputedRef<Map<string, number>> | Yes | - | - |
entries | import("vue").ComputedRef<Map<string, TreeEntry>> | Yes | - | - |
select | (next: string | undefined) => void | Yes | - | - |
setExpanded | (values: readonly string[]) => void | Yes | - | - |
toggle | (key: string) => void | Yes | - | - |
focus | (key: string) => void | Yes | - | - |
activate | (key: string) => void | Yes | - | - |
getItemAttrs | (key: string) => { role: string; tabindex: number; 'aria-label': string | undefined; 'aria-level': number | undefined; 'aria-posinset': number | undefined; 'aria-setsize': number | undefined; 'aria-expanded': boolean | undefined; 'aria-busy': true | undefined; 'aria-selected': boolean | undefined; 'aria-disabled': true | undefined; ref: (element: unknown) => void; onFocusin: (event: FocusEvent) => void; onKeydown: (event: KeyboardEvent) => void; } | Yes | - | - |
rootAttrs | import("vue").ComputedRef<{ role: string; tabindex: number; ref: (element: unknown) => void; }> | Yes | - | - |
const tree = useTree({
items: () => items.value,
value: () => selected.value,
onValueChange: (value) => {
selected.value = value;
},
expanded: () => expanded.value,
onExpandedChange: (values) => {
expanded.value = values;
},
});Refs/getters for value and expanded are controlled sources; static values initialize local state. Bind rootAttrs.value to the tree and getItemAttrs(value) to each focusable row from visibleItems.value. Keep the attribute ref bindings so keyboard navigation and empty-tree recovery can move DOM focus. Bind row clicks to focus(value) and activate(value) and branch disclosure clicks to toggle(value); leaf spacers must bubble to the row. The return value also exposes value, expanded, focusedValue, visibleItems, visibleIndexes, entries, select, and setExpanded. Create the composable inside a component or effect scope so its reactive watcher is disposed with its owner.
Layout and styling
Rows are 48dp by default. Set --md-density: -2 for 40dp or --md-density: -4 for 32dp; the old density prop has been removed. See Density. Customize dimensions and selected colors with the public tokens above. --md-color-container, --md-color-on-container, and --md-state-layer-color preserve the library's semantic color context. For root layout, pass a class to TreeView or TreeItem; for internal styling use the documented data-scope / data-part anatomy. row is the full-width visual and interaction surface; the TreeItem root represents one focusable node.
The tree does not create a scroll container or implement a virtualizer. Let the containing panel own scrolling, with its padding inside the scrolling region and fixed toolbars outside it. Drag and drop, checkbox selection, and multiselect are not included.