Frappe UIFrappe UI

Tree

Displays hierarchical data as a collapsible tree. Renders a forest of roots with keyboard navigation and optional drag-and-drop reparenting/reordering. Connector guides visually link parents to their children.

Default

The simplest tree — pass nodes and tell it which field is the key. Nodes are expanded by default; here Documents carries expanded: false to start collapsed.

  • Guest
    • Downloads
      • download.zip
        • image.png
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',
        expanded: false,
        children: [
          { name: 'somefile.txt', label: 'somefile.txt' },
          { name: 'somefile.pdf', label: 'somefile.pdf' },
        ],
      },
    ],
  },
])
</script>

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

Expand / collapse all

Bind v-model:expanded to a boolean for a master switch — toggling it opens or closes every node. It's two-way, so it also reflects whether all nodes are currently open as the user toggles rows individually.

  • Guest
    • Downloads
      • download.zip
        • image.png
    • Documents
      • somefile.txt
      • somefile.pdf
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button, 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' },
        ],
      },
    ],
  },
])

// `expanded` mirrors whether every node is open (nodes start expanded), so the
// label stays in sync even when rows are toggled individually.
const expanded = ref(true)
</script>

<template>
  <div class="flex flex-col gap-3">
    <Button class="self-start" @click="expanded = !expanded">
      {{ expanded ? 'Collapse all' : 'Expand all' }}
    </Button>
    <div class="w-80">
      <Tree :nodes="nodes" node-key="name" v-model:expanded="expanded" />
    </div>
  </div>
</template>

Indentation guides

guides controls how nesting is drawn: connectors (elbow lines), lines (plain vertical rules), or none. The indentation itself stays either way — guides only changes the lines.

  • 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 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" />
    </div>
  </div>
</template>

Drag and drop

Set draggable to let nodes be dragged onto one another to reparent, or between siblings to reorder. A move predicate gates where drops are allowed (here, only into folders), and @drag-end hands you the committed move to persist. This example is fully working — drag a file into a folder and it stays there. Flip Disable interaction to see the disabled state freeze drag and expand/collapse.

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

// 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 = []
      parent.expanded = true
      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"
        :disabled="disabled"
        draggable
        :move="move"
        @drag-end="onDragEnd"
      />
    </div>
  </div>
</template>

List view with avatars and row actions

Use the #item-prefix, #item-label, and #item-suffix slots to turn the tree into a rich list — an avatar on the left, a two-line label, and a row action (an add button) on the right. guides="none" drops the connector lines for a plain list look.

  • 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',
      },
    ],
  },
])

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">
      <!-- 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="plus"
          :aria-label="`Add report under ${node.label}`"
          @click.stop="addReport(node)"
        />
      </template>
    </Tree>
  </div>
</template>

Usage

vue
<script setup>
import { ref } from 'vue'
import { Tree } from 'frappe-ui'

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

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

Node shape

Each node is a plain object with a label (display text) and optional children. Its unique id lives under the field named by nodeKey (e.g. name). A node is a leaf when children is missing or empty. Extra fields are passed through to the slots, so you can render avatars, roles, badges, etc.

To display a field other than label, use the #item-label slot rather than remapping.

Expansion

Each node owns its own state via an expanded field — the source of truth the tree reads and writes as rows toggle, so expansion travels with your data. Nodes are expanded by default; set expanded: false to start one collapsed.

v-model:expanded is a separate, optional boolean switch for the whole tree: toggle it to open or close everything at once (see Expand / collapse all). It's two-way, reflecting whether all nodes are currently open.

Clicking a row, or pressing Enter/Space on it, toggles that node.

Keyboard

Following the WAI-ARIA Tree View pattern: / move between visible rows, expands or steps into children, collapses or steps to the parent, Home/End jump to the first/last row, Enter/Space toggle expansion, and typing letters jumps to the next matching label.

Drag and drop

Set draggable to enable dragging. The tree resolves the drop position from the cursor (before / inside / after) and shows a live indicator.

  • move(ctx) — an optional predicate called as you hover. Return false to reject a target (it shows the no-drop cursor and hides the indicator). Built-in guards already reject drop-on-self and drop-into-own-descendant, so move only carries your domain rules. ctx is { node, target, position }.
  • @drag-start(node) — fires when a drag is picked up.
  • @drag-end(info) — fires when the drag ends. info is a DropInfo on a committed move, or null if the drag was cancelled. Apply it to your data and update nodes.
vue
<Tree
  :nodes="nodes"
  node-key="name"
  draggable
  :move="({ node, target, position }) => Array.isArray(target.children)"
  @drag-end="onDragEnd"
/>

DropInfo is { node, from, to, position, oldIndex, newIndex }from/to are the old/new parent keys (null at root level) and newIndex is the node's final index within its new parent, already accounting for its removal.

Customizing rows

Use the #item slot to fully replace a row (you receive toggle plus state), or the lighter #item-label, #item-prefix, and #item-suffix slots to keep the default layout. Style via the data-slot, data-state, data-drop and data-level attributes rather than class props.

Row height and indentation are CSS variables — override them in CSS rather than through props:

css
.my-tree {
  --tree-row-height: 40px;
  --tree-indent: 24px;
}

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.
 */
export type TreeNode = {
  [key: string]: unknown
  label?: string
  children?: TreeNode[]
  /**
   * Whether this node is expanded — the per-node source of truth. Expanded by
   * default; set `false` to start it collapsed.
   */
  expanded?: boolean
}

/** 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
}

/** 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')

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
= false
boolean

Expand/collapse-all switch. Toggling it writes that value into every node's `expanded` field. Two-way: it also reflects whether all collapsible nodes are currently open, so a bound button stays in sync. Per-node state lives on the nodes themselves (`node.expanded`).

item
TreeNodeSlotProps
item-prefix
Omit<TreeNodeSlotProps, "toggle">
item-label
Omit<TreeNodeSlotProps, "toggle">
item-suffix
Omit<TreeNodeSlotProps, "toggle">
empty
update:expanded
[value: boolean]

Fired when the expanded changes.

drag-start
[node: TreeNode]
drag-end
[info: DropInfo | null]