- Group A
- Node A.1
- Group A.2
- Node A.2.1
- Node A.2.2
- Group B
- Node B.1
- Node B.2
<script setup lang="ts">
import { ref } from 'vue'
import { Tree, type TreeValue } from '@hina-ui/vue'
import { nodes } from './data'
const checked = ref<TreeValue[]>(['a-2-1'])
</script>
<template>
<Tree
v-model="checked"
multiple
:items="nodes"
:default-expanded="['a', 'a-2', 'b']"
aria-label="Tree selection"
class="w-80 max-w-full"
/>
</template>
Usage
import { Tree, type TreeNode, type TreeValue } from '@hina-ui/vue'
Tree renders a tree directly. Use TreeSelect for a single-selection popup with a trigger. Supply nodes through items and nested levels through children. Clicking the disclosure arrow only expands or collapses the node; clicking its text or pressing Space or Enter changes selection.
Single selection is the default. v-model holds the node’s value; selecting it again clears the value to null.
- Group A
- Node A.1
- Group A.2
- Group B
Selected value:—
<script setup lang="ts">
import { ref } from 'vue'
import { Stack, Text, Tree, type TreeValue } from '@hina-ui/vue'
import { nodes } from './data'
const selected = ref<TreeValue | null>(null)
</script>
<template>
<Stack class="w-80 max-w-full">
<Tree
v-model="selected"
:items="nodes"
:default-expanded="['a']"
aria-label="Single selection tree"
/>
<Text size="sm" tone="muted">Selected value:{{ selected ?? '—' }}</Text>
</Stack>
</template>
Examples
Multiple checks and indeterminate parents
Set multiple to display checkboxes and bind v-model to a TreeValue[]. Checking a parent selects all available descendants; unchecking it clears them. Clicking an indeterminate parent completes the selection. Parents show a minus when some descendants are checked and a check when all available children are checked.
States are derived from the entire tree. Collapsing nodes does not clear selection or change indeterminate states. Updates contain every fully checked node, including automatically checked parents; indeterminate parents are excluded. Known nodes follow tree preorder, and bound values absent from the current items are preserved.
External values can contain only leaves; parent states are derived automatically. Passing a parent value checks its available descendants. Initialization, expansion, and data refresh do not emit selection changes by themselves. Checkboxes share the visuals and motion of Checkbox.
- Group A
- Node A.1
- Group A.2
- Node A.2.1
- Node A.2.2
- Group B
- Node B.1
- Node B.2
Bound values:a-2-1
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Inline, Stack, Text, Tree, type TreeValue } from '@hina-ui/vue'
import { nodes } from './data'
const checked = ref<TreeValue[]>(['a-2-1'])
</script>
<template>
<Stack class="w-96 max-w-full">
<Tree
v-model="checked"
multiple
:items="nodes"
:default-expanded="['a', 'a-2', 'b']"
aria-label="Cascading checks"
/>
<Inline>
<Button
size="sm"
variant="soft"
tone="neutral"
@click="checked = nodes.map(node => node.value)"
>
Check all
</Button>
<Button size="sm" variant="ghost" tone="neutral" @click="checked = []">Clear</Button>
</Inline>
<Text size="sm" tone="muted" class="break-all">
Bound values:{{ checked.join(', ') || '—' }}
</Text>
</Stack>
</template>
Controlled expansion
defaultExpanded supplies initial expanded nodes; v-model:expanded controls expansion externally. Both use each node’s original value. An explicit expanded value takes precedence, including an empty array. Nodes without children or with children: [] have no disclosure arrow.
- Group A
- Node A.1
- Group A.2
- Group B
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Inline, Stack, Tree, type TreeValue } from '@hina-ui/vue'
import { nodes } from './data'
const expanded = ref<TreeValue[]>(['a'])
const checked = ref<TreeValue[]>(['a-2-1'])
</script>
<template>
<Stack class="w-80 max-w-full">
<Inline>
<Button size="sm" variant="soft" tone="neutral" @click="expanded = ['a', 'a-2', 'b']">
Expand all
</Button>
<Button size="sm" variant="ghost" tone="neutral" @click="expanded = []">Collapse all</Button>
</Inline>
<Tree
v-model="checked"
v-model:expanded="expanded"
multiple
:items="nodes"
aria-label="Controlled expansion"
/>
</Stack>
</template>
Custom nodes
The node slot replaces the text area; trailing sits at the end of the row. Both receive the original node and its selected, indeterminate, expanded, and disabled states. selected means fully selected; an indeterminate node has selected: false and indeterminate: true. The disclosure arrow and checkbox remain provided by the component.
The example uses Tag to show full and partial selection.
- Group APartial
- Node A.1
- Group A.2Partial
- Node A.2.1Checked
- Node A.2.2
- Group B
- Node B.1
- Node B.2
<script setup lang="ts">
import { ref } from 'vue'
import { File, Folder, FolderOpen } from '@lucide/vue'
import { Inline, Tag, Text, Tree, type TreeValue } from '@hina-ui/vue'
import { nodes } from './data'
const checked = ref<TreeValue[]>(['a-2-1'])
</script>
<template>
<Tree
v-model="checked"
multiple
:items="nodes"
:default-expanded="['a', 'a-2', 'b']"
aria-label="Custom nodes"
class="w-96 max-w-full"
>
<template #node="{ node, expanded }">
<Inline as="span" :wrap="false" gap="sm">
<FolderOpen
v-if="node.children?.length && expanded"
class="text-muted size-4 shrink-0"
aria-hidden="true"
/>
<Folder
v-else-if="node.children?.length"
class="text-muted size-4 shrink-0"
aria-hidden="true"
/>
<File v-else class="text-muted size-4 shrink-0" aria-hidden="true" />
<Text as="span" size="sm">{{ node.label }}</Text>
</Inline>
</template>
<template #trailing="{ selected, indeterminate }">
<Tag v-if="indeterminate" size="sm" tone="neutral">Partial</Tag>
<Tag v-else-if="selected" size="sm">Checked</Tag>
</template>
</Tree>
</template>
Disabled
A node’s disabled flag disables the node and its subtree, blocking clicks, keyboard selection, and expansion. Cascading checks skip disabled subtrees, which also do not contribute to their parents’ checked or indeterminate states. Existing bound values are preserved. An available parent with only disabled children can still be checked independently.
The component’s disabled prop disables the whole tree while keeping existing selection visible. FormField automatically supplies disabled and validation states, labels, and description associations.
Disabled subtree
- Root
- Available node
- Disabled node
- Descendant of disabled node
Disabled tree
- Root
- Available node
- Disabled node
<script setup lang="ts">
import { Stack, Text, Tree, type TreeNode } from '@hina-ui/vue'
const items: TreeNode[] = [
{
value: 'root',
label: 'Root',
children: [
{ value: 'available', label: 'Available node' },
{
value: 'disabled',
label: 'Disabled node',
disabled: true,
children: [{ value: 'child', label: 'Descendant of disabled node' }],
},
],
},
]
</script>
<template>
<Stack class="w-80 max-w-full">
<Text size="sm" tone="muted">Disabled subtree</Text>
<Tree
multiple
:items="items"
:default-expanded="['root', 'disabled']"
aria-label="Disabled subtree"
/>
<Text size="sm" tone="muted">Disabled tree</Text>
<Tree
multiple
disabled
:items="items"
:model-value="['available']"
:default-expanded="['root']"
aria-label="Disabled tree"
/>
</Stack>
</template>
Scrolling
The component has no height limit, panel background, or border. Wrap it in ScrollArea to limit its visible height.
- Group
- Node 1
- Node 2
- Node 3
- Node 4
- Node 5
- Node 6
- Node 7
- Node 8
- Node 9
- Node 10
- Node 11
- Node 12
- Node 13
- Node 14
- Node 15
- Node 16
- Node 17
- Node 18
- Node 19
- Node 20
- Node 21
- Node 22
- Node 23
- Node 24
- Node 25
- Node 26
- Node 27
- Node 28
- Node 29
- Node 30
- Node 31
- Node 32
- Node 33
- Node 34
- Node 35
- Node 36
- Node 37
- Node 38
- Node 39
- Node 40
<script setup lang="ts">
import { ref } from 'vue'
import { ScrollArea, Tree, type TreeNode, type TreeValue } from '@hina-ui/vue'
const checked = ref<TreeValue[]>([])
const items: TreeNode[] = [
{
value: 'root',
label: 'Group',
children: Array.from({ length: 40 }, (_, index) => ({
value: index,
label: `Node ${index + 1}`,
})),
},
]
</script>
<template>
<ScrollArea class="h-64 w-80 max-w-full">
<Tree
v-model="checked"
multiple
:items="items"
:default-expanded="['root']"
aria-label="Scrollable tree"
/>
</ScrollArea>
</template>
Empty content
An empty tree displays localized text, replaceable through the empty slot. Empty content uses role="status" instead of rendering a role="tree" without nodes.
No nodes yet
<script setup lang="ts">
import { Stack, Text, Tree } from '@hina-ui/vue'
</script>
<template>
<Stack class="w-80 max-w-full">
<Tree :items="[]" aria-label="Default empty state" />
<Tree :items="[]" aria-label="Custom empty state">
<template #empty><Text size="sm" tone="muted">No nodes yet</Text></template>
</Tree>
</Stack>
</template>
Virtual scrolling
virtualize renders rows near the viewport, sharing measurement and scrolling with VirtualList. It is off by default. Pass { estimateSize, overscan } to configure estimated row height and the buffer on each side; actual heights are measured. Keyboard navigation covers the full collection and skips disabled items. Only expanded, visible nodes participate in the window; parent-child navigation and selection do not depend on mounted rows. maxHeight defaults to 320px and applies only with virtualization enabled. It accepts a number or CSS length. Rows unmount outside the rendered range; keep persistent slot state outside the row, keyed by its unique value.
Selected: 1
<script setup lang="ts">
import { ref } from 'vue'
import { Tree, Stack, Text } from '@hina-ui/vue'
const selected = ref<Array<string | number>>([7890])
const options = Array.from({ length: 10000 }, (_, index) => ({
value: index,
label: `Item ${String(index + 1).padStart(5, '0')}`,
disabled: index % 97 === 0,
}))
const items = [{ value: 'root', label: 'All nodes', children: options }]
</script>
<template>
<Stack gap="sm" class="w-80 max-w-full">
<Tree
v-model="selected"
:items="items"
:default-expanded="['root']"
:virtualize="{ estimateSize: 36, overscan: 6 }"
multiple
:max-height="320"
aria-label="Ten thousand items"
/>
<Text size="sm" tone="muted">Selected: {{ selected.length }}</Text>
</Stack>
</template>
Keyboard and accessibility
- The root is
role="tree"and nodes arerole="treeitem", preserving levels, sibling positions, and expansion state. Provide a name witharia-label,aria-labelledby, or FormField. - Single selection uses
aria-selected. Multiple checks usearia-checked, withmixedfor indeterminate parents. Visual checkboxes do not add extra Tab stops. - Up and Down move focus; Home and End reach the first and last available nodes. Space and Enter change selection.
- The arrow toward the line end expands or enters children; the arrow toward the line start collapses or returns to the parent. Left and Right swap in RTL. Disabled nodes are skipped during keyboard navigation.
API
Props and models
| Prop | Type | Default | Description |
|---|---|---|---|
items | TreeNode[] | — | Required. Tree nodes |
virtualize | VirtualizeOptions | false | Virtual scrolling; content-based estimate, overscan 6 |
maxHeight | number | string | 320 | Maximum height of the virtual viewport |
multiple | boolean | false | Enable cascading checkbox multiselection |
modelValue | TreeValue | TreeValue[] | null | — | Single value or array; supports v-model |
defaultExpanded | TreeValue[] | [] | Initially expanded nodes |
expanded | TreeValue[] | — | Expanded nodes; supports v-model:expanded |
disabled | boolean | false | Disable the entire tree |
invalid | boolean | false | Mark validation as failed |
class | string | — | Classes appended to the root |
Slots
| Slot | Payload | Description |
|---|---|---|
node | TreeNodeSlot | Node content |
trailing | TreeNodeSlot | End-of-row content; occupies no space when omitted |
empty | — | Empty content |
Node types
export type TreeValue = string | number
export interface TreeNode {
value: TreeValue
label: string
description?: string
disabled?: boolean
children?: TreeNode[]
}
export interface TreeNodeSlot {
node: TreeNode
selected: boolean
indeterminate: boolean
expanded: boolean
disabled: boolean
}
Each value must be unique within the tree. Numeric 1 and string '1' are distinct values. description appears below the label by default; the slot’s disabled state includes inherited disabling from ancestors and the whole component.
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }