Tree

A standalone tree with expandable nodes, cascading checks, and indeterminate parents.

  • 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>
vue

Usage

import { Tree, type TreeNode, type TreeValue } from '@hina-ui/vue'
ts

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

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>
vue

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>
vue

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
<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>
vue

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>
vue

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
<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>
vue

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>
vue

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

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>
vue

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>
vue

Keyboard and accessibility

  • The root is role="tree" and nodes are role="treeitem", preserving levels, sibling positions, and expansion state. Provide a name with aria-label, aria-labelledby, or FormField.
  • Single selection uses aria-selected. Multiple checks use aria-checked, with mixed for 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
}
ts

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 }
ts