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.

  • Guest
    • Downloads
      • download.zip
        • image.png
    • Documents
      • somefile.txt
      • somefile.pdf
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' },
        ],
      },
    ],
  },
])
</script>

<template>
  <div class="w-80">
    <Tree :nodes="nodes" node-key="name" default-expanded />
  </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" default-expanded />
    </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 expanded = ref<string[]>(['guest', 'downloads', 'documents', 'archive'])
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 = []
      dest = parent.children
      if (!expanded.value.includes(info.to as string))
        expanded.value = [...expanded.value, info.to as string]
    }
  }
  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>

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

const expanded = ref<string[]>(['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"
      v-model:expanded="expanded"
      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' },
    ],
  },
])
const expanded = ref(['src'])
</script>

<template>
  <Tree :nodes="nodes" node-key="name" v-model:expanded="expanded" />
</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

Two inputs affect which rows are open — reach for one:

  • v-model:expanded — the array of open node keys, and the source of truth. Bind it to control or observe expansion; leave it unbound and the tree tracks the set internally. This is the one you want in most cases.
  • defaultExpanded — a boolean shortcut for "start with everything open." It seeds expanded once on first load (and waits for async nodes), then steps aside — it does nothing once you provide your own expanded keys.

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

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[]
}

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

  /**
   * Start with every node expanded. A one-shot convenience that seeds the open
   * set on first load (async-safe — it waits for `nodes` to arrive). To open
   * specific nodes or track expansion, use `v-model:expanded` instead; this is
   * ignored once you provide your own keys.
   * @default false
   */
  defaultExpanded?: boolean

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

defaultExpanded
= false
boolean

Start with every node expanded. A one-shot convenience that seeds the open set on first load (async-safe — it waits for `nodes` to arrive). To open specific nodes or track expansion, use `v-model:expanded` instead; this is ignored once you provide your own keys.

disabled
= false
boolean

Disable all interaction — expand/collapse and drag.

expanded
= []
TreeKey[]

The keys of the currently expanded nodes — the live source of truth for which rows are open. Controlled or uncontrolled. Use `defaultExpanded` only for the simple "start fully expanded" case.

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

Fired when the expanded changes.

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