Tree ​

Shows nested data as rows that open and close, such as folders or an org chart. It supports keyboard navigation and, when you turn it on, drag and drop to move nodes.

  • Guest
    • Downloads
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Tree } from 'frappe-ui'
import type { TreeNode } from '../types'

const nodes = ref<TreeNode[]>([
  {
    name: 'guest',
    label: 'Guest',
    children: [
      {
        name: 'downloads',
        label: 'Downloads',
        children: [
          {
            name: 'download.zip',
            label: 'download.zip',
            children: [{ name: 'image.png', label: 'image.png' }],
          },
        ],
      },
      {
        name: 'documents',
        label: 'Documents',
        children: [
          { name: 'somefile.txt', label: 'somefile.txt' },
          { name: 'somefile.pdf', label: 'somefile.pdf' },
        ],
      },
    ],
  },
])

// Expansion is a list of node keys. A key that is absent stays collapsed, so
// `Documents` starts closed and the tree writes nothing back to `nodes`.
const expanded = ref(['guest', 'downloads'])
</script>

<template>
  <div class="w-80">
    <Tree :nodes="nodes" node-key="name" v-model:expanded="expanded" />
  </div>
</template>

Examples ​

Expand and collapse all ​

Buttons above the tree call expandAll(), collapseAll() and toggle(key) on a template ref. They write the same expanded model that clicking a row does.

  • Guest

expanded: guest

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Tree } from 'frappe-ui'
import type { TreeExposed, TreeNode } from '../types'

const nodes = ref<TreeNode[]>([
  {
    name: 'guest',
    label: 'Guest',
    children: [
      {
        name: 'downloads',
        label: 'Downloads',
        children: [
          {
            name: 'download.zip',
            label: 'download.zip',
            children: [{ name: 'image.png', label: 'image.png' }],
          },
        ],
      },
      {
        name: 'documents',
        label: 'Documents',
        children: [
          { name: 'somefile.txt', label: 'somefile.txt' },
          { name: 'somefile.pdf', label: 'somefile.pdf' },
        ],
      },
    ],
  },
])

// The model holds the keys of the open nodes, so it doubles as a readout.
const expanded = ref<string[]>(['guest'])

// `expandAll` / `collapseAll` write the same model through a template ref.
const tree = ref<TreeExposed | null>(null)
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex gap-2">
      <Button @click="tree?.expandAll()">Expand all</Button>
      <Button @click="tree?.collapseAll()">Collapse all</Button>
      <Button @click="tree?.toggle('documents')">Toggle Documents</Button>
    </div>
    <div class="w-80">
      <Tree
        ref="tree"
        :nodes="nodes"
        node-key="name"
        v-model:expanded="expanded"
      />
    </div>
    <p class="text-sm text-ink-gray-5">
      expanded: {{ expanded.length ? expanded.join(', ') : '(none)' }}
    </p>
  </div>
</template>

Folder outline ​

guides sets the lines that link parents to children: connectors, lines or none.

  • Guest
    • Downloads
      • download.zip
        • image.png
    • Documents
      • somefile.txt
      • somefile.pdf
vue
<script setup lang="ts">
import { ref } from 'vue'
import { TabButtons, Tree } from 'frappe-ui'
import type { TreeNode } from '../types'

const nodes = ref<TreeNode[]>([
  {
    name: 'guest',
    label: 'Guest',
    children: [
      {
        name: 'downloads',
        label: 'Downloads',
        children: [
          {
            name: 'download.zip',
            label: 'download.zip',
            children: [{ name: 'image.png', label: 'image.png' }],
          },
        ],
      },
      {
        name: 'documents',
        label: 'Documents',
        children: [
          { name: 'somefile.txt', label: 'somefile.txt' },
          { name: 'somefile.pdf', label: 'somefile.pdf' },
        ],
      },
    ],
  },
])

const expanded = ref(['guest', 'downloads', 'download.zip', 'documents'])

const guides = ref<'connectors' | 'lines' | 'none'>('connectors')
const guideOptions = [
  { label: 'Connectors', value: 'connectors' },
  { label: 'Lines', value: 'lines' },
  { label: 'None', value: 'none' },
]
</script>

<template>
  <div class="flex flex-col gap-4">
    <TabButtons v-model="guides" :options="guideOptions" />
    <div class="w-80">
      <Tree
        :nodes="nodes"
        node-key="name"
        :guides="guides"
        v-model:expanded="expanded"
      />
    </div>
  </div>
</template>

Move files between folders ​

draggable lets people drag a file into a folder or between siblings. The move function allows drops only into folders, and @drag-end saves the move to the data. Turn on Disable interaction to see disabled stop both dragging and opening rows.

  • Guest
    • Downloads
      • archive
        • image.png
    • Documents
      • resume.pdf
      • notes.txt
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Switch, Tree } from 'frappe-ui'
import type { DropInfo, MoveContext, TreeNode } from '../types'

const nodes = ref<TreeNode[]>([
  {
    name: 'guest',
    label: 'Guest',
    children: [
      {
        name: 'downloads',
        label: 'Downloads',
        children: [
          {
            name: 'archive',
            label: 'archive',
            children: [{ name: 'image.png', label: 'image.png' }],
          },
        ],
      },
      {
        name: 'documents',
        label: 'Documents',
        children: [
          { name: 'resume.pdf', label: 'resume.pdf' },
          { name: 'notes.txt', label: 'notes.txt' },
        ],
      },
    ],
  },
])

const disabled = ref(false)
const expanded = ref(['guest', 'downloads', 'archive', 'documents'])

// Folders (nodes with a `children` array) can receive drops; files cannot.
function move({ target, position }: MoveContext) {
  if (position === 'inside') return Array.isArray(target.children)
  return true
}

// Locate a node by key, returning the array that holds it and its index.
function locate(
  list: TreeNode[],
  key: string,
): { list: TreeNode[]; index: number } | null {
  for (let i = 0; i < list.length; i++) {
    if (list[i].name === key) return { list, index: i }
    const children = list[i].children
    if (children) {
      const found = locate(children, key)
      if (found) return found
    }
  }
  return null
}

// Persist a committed move by splicing the node into its new position.
function onDragEnd(info: DropInfo | null) {
  if (!info) return
  const roots = nodes.value
  const src = locate(roots, info.node.name as string)
  if (!src) return
  const [moved] = src.list.splice(src.index, 1)

  let dest = roots
  if (info.to !== null) {
    const hit = locate(roots, info.to as string)
    const parent = hit?.list[hit.index]
    if (parent) {
      if (!parent.children) parent.children = []
      // Open the new parent so the moved node stays visible.
      if (!expanded.value.includes(info.to as string))
        expanded.value = [...expanded.value, info.to as string]
      dest = parent.children
    }
  }
  dest.splice(info.newIndex, 0, moved)
}
</script>

<template>
  <div class="flex flex-col gap-3">
    <Switch v-model="disabled" label="Disable interaction" />
    <div class="w-80">
      <Tree
        :nodes="nodes"
        node-key="name"
        v-model:expanded="expanded"
        :disabled="disabled"
        draggable
        :move="move"
        @drag-end="onDragEnd"
      />
    </div>
  </div>
</template>

Reporting lines ​

The #item-prefix, #item-label and #item-suffix slots turn each row into an avatar, a name with a role, and an add button. guides="none" removes the lines.

  • James Cooper
    James CooperHead of Sales
    • Wade Warren
      Wade WarrenAccount Executive
      • Ethan Howard
        Ethan HowardSales Rep
    • Cody Fisher
      Cody FisherAccount Executive
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Avatar, Button, Tree } from 'frappe-ui'
import type { TreeNode } from '../types'

const nodes = ref<TreeNode[]>([
  {
    id: 'james',
    label: 'James Cooper',
    role: 'Head of Sales',
    image: 'https://i.pravatar.cc/80?img=11',
    children: [
      {
        id: 'wade',
        label: 'Wade Warren',
        role: 'Account Executive',
        image: 'https://i.pravatar.cc/80?img=12',
        children: [
          {
            id: 'ethan',
            label: 'Ethan Howard',
            role: 'Sales Rep',
            image: 'https://i.pravatar.cc/80?img=13',
          },
        ],
      },
      {
        id: 'cody',
        label: 'Cody Fisher',
        role: 'Account Executive',
        image: 'https://i.pravatar.cc/80?img=15',
      },
    ],
  },
])

const expanded = ref(['james', 'wade'])

function addReport(node: TreeNode) {
  console.log('add report under', node.id)
}
</script>

<template>
  <div class="w-96" style="--tree-row-height: 48px">
    <Tree
      :nodes="nodes"
      node-key="id"
      guides="none"
      v-model:expanded="expanded"
    >
      <!-- Avatar on the left -->
      <template #item-prefix="{ node }">
        <Avatar
          :image="node.image as string"
          :label="node.label as string"
          size="lg"
        />
      </template>

      <template #item-label="{ node }">
        <div class="flex min-w-0 flex-col">
          <span class="truncate text-base text-ink-gray-8">{{
            node.label
          }}</span>
          <span class="truncate text-sm text-ink-gray-5">{{ node.role }}</span>
        </div>
      </template>

      <!-- Add action -->
      <template #item-suffix="{ node }">
        <Button
          variant="ghost"
          icon="lucide-plus"
          :aria-label="`Add report under ${node.label}`"
          @click.stop="addReport(node)"
        />
      </template>
    </Tree>
  </div>
</template>

Behavior ​

Node shape ​

Pass the top-level nodes as nodes. Each node is a plain object with a label and optional children. Its unique key lives in the field named by nodeKey, such as name. A node with no children, or an empty array, is a leaf. Extra fields reach the slots, so you can show avatars, roles or badges.

vue
<script setup>
const nodes = ref([
  {
    name: 'src',
    label: 'src',
    children: [
      { name: 'index.ts', label: 'index.ts' },
      { name: 'app.vue', label: 'app.vue' },
    ],
  },
])
const expanded = ref(['src'])
</script>

<template>
  <Tree :nodes="nodes" node-key="name" v-model:expanded="expanded" />
</template>

To show a field other than label, use the #item-label slot rather than renaming the field. The tree never writes to the objects you pass in.

Expansion ​

v-model:expanded holds the keys of the open nodes. A missing key means closed, so a tree with no bound model shows only its top-level nodes. Clicking a row, or pressing Enter or Space on it, opens or closes it.

Every change assigns a new array, so shallow watchers, immutable stores and undo logs see it.

Drag and drop ​

Set draggable to turn on dragging. The tree works out the drop position from the pointer (before, inside or after) and shows where the node will land.

  • move(ctx) runs while you hover a target. Return false to reject it: the pointer shows no-drop and the marker hides. The tree already rejects a drop on the node itself or into its own children, so move only needs your own rules. ctx is { node, target, position }.
  • @drag-start fires with the node when a drag starts.
  • @drag-end fires when the drag ends, with a DropInfo for a finished move or null for a cancelled one. Apply the move to your data and update nodes.
vue
<Tree
  :nodes="nodes"
  node-key="name"
  draggable
  :move="({ target }) => Array.isArray(target.children)"
  @drag-end="onDragEnd"
/>

DropInfo is { node, from, to, position, oldIndex, newIndex }. from and to are the keys of the old and new parents, null at the top level. newIndex is the node's final index in its new parent, counted after it was removed from the old one.

Disabled ​

disabled stops people from opening, closing and dragging rows.

Custom rows ​

#item-prefix, #item-label and #item-suffix fill parts of the default row. #item replaces the whole row and receives toggle along with the row's state.

Accessibility ​

The tree follows the WAI-ARIA tree view pattern, with role="tree", treeitem and group. Each row reports its level, its position among its siblings and, for a parent, whether it is open.

KeysAction
ArrowDown / ArrowUpMove to the next or previous visible row
ArrowRightOpen the row, or move to its first child
ArrowLeftClose the row, or move to its parent
Home / EndMove to the first or last row
Enter / SpaceOpen or close the row
LettersMove to the next row whose label starts with them

Drag and drop announces "Picked up", "Moved" and "Cancelled move" to screen readers.

API Reference ​

Show types
typescript
import type { ComputedRef, InjectionKey, Ref } from 'vue'

/** A node key — the value read from `nodeKey` on each node. */
export type TreeKey = string | number

/**
 * A tree node. Carries a display `label`, nested `children`, and a unique id
 * under the field named by the `nodeKey` prop. Any extra fields are preserved
 * and passed through to slots.
 *
 * A node holds your data only. Expansion state lives outside it, in the
 * `expanded` model, so the tree never writes to the objects you pass in.
 */
export type TreeNode = {
  [key: string]: unknown
  label?: string
  children?: TreeNode[]
}

/** Where a dragged node lands relative to the hovered target. */
export type DropPosition = 'inside' | 'before' | 'after'

/** Context passed to the `move` predicate while dragging. */
export interface MoveContext {
  /** The node being dragged. */
  node: TreeNode
  /** The node currently hovered as the drop target. */
  target: TreeNode
  /** Resolved drop position relative to `target`. */
  position: DropPosition
}

/**
 * The committed move handed to `@drag-end`. `null` is emitted instead when a
 * drag is cancelled (Escape, or released without a valid landing).
 */
export interface DropInfo {
  /** The moved node. */
  node: TreeNode
  /** Key of the previous parent, or `null` if it was a root. */
  from: TreeKey | null
  /** Key of the new parent, or `null` when moved to root level. */
  to: TreeKey | null
  /** Drop position relative to the target node. */
  position: DropPosition
  /** Index the node occupied in its previous parent. */
  oldIndex: number
  /** Final index of the node within its new parent's children. */
  newIndex: number
}

export interface TreeProps {
  /**
   * Forest roots to render. Each node may contain nested children to form the
   * tree structure.
   */
  nodes: TreeNode[]

  /**
   * Name of the field that uniquely identifies each node.
   * @default 'key'
   */
  nodeKey?: string

  /**
   * Enable drag-and-drop. Nodes can be dragged onto one another to reparent, or
   * between siblings to reorder.
   * @default false
   */
  draggable?: boolean

  /**
   * Gate a drop while dragging. Receives the live drag context and returns
   * whether the drop is allowed — a rejected target shows the no-drop cursor and
   * hides the drop indicator. Built-in guards (drop-on-self, drop-into-own-
   * descendant) run first, so this only carries your domain rules.
   */
  move?: (ctx: MoveContext) => boolean

  /**
   * Visual style of the indentation guides.
   * @default 'connectors'
   */
  guides?: 'connectors' | 'lines' | 'none'

  /**
   * Disable all interaction — expand/collapse and drag.
   * @default false
   */
  disabled?: boolean

  /**
   * Keys of the expanded nodes. Two-way via `v-model:expanded` — the tree
   * replaces the array as rows toggle and never writes to your nodes. A key
   * that is absent means that node is collapsed, so the default renders only
   * the roots.
   * @default []
   */
  expanded?: TreeKey[]
}

/** State + callbacks shared from `Tree` down to every recursive `TreeItem`. */
export interface TreeContext {
  nodeKey: Ref<string>
  guides: Ref<'connectors' | 'lines' | 'none'>
  draggable: Ref<boolean>
  disabled: Ref<boolean>

  focusedKey: Ref<TreeKey | null>

  keyOf: (node: TreeNode) => TreeKey
  labelOf: (node: TreeNode) => string
  childrenOf: (node: TreeNode) => TreeNode[]
  hasChildren: (node: TreeNode) => boolean

  isExpanded: (node: TreeNode) => boolean
  toggle: (node: TreeNode) => void
  focus: (key: TreeKey) => void

  registerItem: (key: TreeKey, el: HTMLElement) => void
  unregisterItem: (key: TreeKey) => void

  // Drag-and-drop
  dragSourceKey: ComputedRef<TreeKey | null>
  dropTargetKey: ComputedRef<TreeKey | null>
  dropPosition: ComputedRef<DropPosition | null>
  onDragStart: (e: DragEvent, node: TreeNode, parent: TreeNode | null) => void
  onDragOver: (e: DragEvent, node: TreeNode) => void
  onDragLeave: (node: TreeNode) => void
  onDrop: (node: TreeNode, parent: TreeNode | null) => void
  onDragEnd: () => void
}

export const TreeContextKey: InjectionKey<TreeContext> = Symbol('TreeContext')

/**
 * What a `<Tree>` template ref exposes. These write the `expanded` model, so
 * they are programmatic — `disabled`, which freezes user interaction, does not
 * block them.
 */
export interface TreeExposed {
  /**
   * Opens the node with this key. A key whose children have not loaded yet
   * opens as soon as they arrive. Like every method here, it works while the
   * tree is `disabled`.
   */
  expand: (key: TreeKey) => void
  /** Closes the node with this key. */
  collapse: (key: TreeKey) => void
  /** Flips the node with this key. */
  toggle: (key: TreeKey) => void
  /** Opens every node that has children, keeping any keys already open. */
  expandAll: () => void
  /** Closes every node. */
  collapseAll: () => void
}

export interface TreeNodeSlotProps {
  node: TreeNode
  level: number
  expanded: boolean
  hasChildren: boolean
  focused: boolean
  disabled: boolean
  toggle: () => void
}
nodes*
TreeNode[]

Forest roots to render. Each node may contain nested children to form the tree structure.

nodeKey
= "key"
string

Name of the field that uniquely identifies each node.

draggable
= false
boolean

Enable drag-and-drop. Nodes can be dragged onto one another to reparent, or between siblings to reorder.

move
((ctx: MoveContext) => boolean)

Gate a drop while dragging. Receives the live drag context and returns whether the drop is allowed — a rejected target shows the no-drop cursor and hides the drop indicator. Built-in guards (drop-on-self, drop-into-own- descendant) run first, so this only carries your domain rules.

guides
= "connectors"
"connectors" | "lines" | "none"

Visual style of the indentation guides.

disabled
= false
boolean

Disable all interaction — expand/collapse and drag.

expanded
= []
TreeKey[]

Keys of the expanded nodes. Two-way via `v-model:expanded` — the tree replaces the array as rows toggle and never writes to your nodes. A key that is absent means that node is collapsed, so the default renders only the roots.

item
TreeNodeSlotProps

Fully replaces a row's default rendering — receives `toggle` plus node state.

item-prefix
Omit<TreeNodeSlotProps, "toggle">

Leading content before the label, inside the default row layout.

item-label
Omit<TreeNodeSlotProps, "toggle">

Overrides the row's label text, inside the default row layout.

item-suffix
Omit<TreeNodeSlotProps, "toggle">

Trailing content after the label, inside the default row layout.

empty
—

Rendered instead of the tree when `nodes` is empty.

update:expanded
[value: TreeKey[]]

Fired when the expanded changes.

drag-start
[node: TreeNode]

Fired when a drag is picked up.

drag-end
[info: DropInfo | null]

Fired when a drag ends: the committed move, or `null` if it was cancelled.

expand
(key: TreeKey) => void

Opens the node with this key. A key whose children have not loaded yet opens as soon as they arrive. Like every method here, it works while the tree is `disabled`.

collapse
(key: TreeKey) => void

Closes the node with this key.

toggle
(key: TreeKey) => void

Flips the node with this key.

expandAll
() => void

Opens every node that has children, keeping any keys already open.

collapseAll
() => void

Closes every node.