Collapsible Tree

Virtualized hierarchical list with expandable/collapsible nodes
A hierarchical list where items can be expanded or collapsed. Virtualization ensures smooth scrolling even with thousands of nodes.

How to build a feature like this

A tree is recursive, but a virtual scroller is linear: it positions a flat array of rows by integer index and mounts only the window around the scroll offset. The bridge is a flattening step - keep the real hierarchy in your data, then derive the array of visible rows (each node followed by its descendants while it is expanded) and pass that to :items. Expanding or collapsing then mutates the model and re-runs the flatten; nothing in the DOM tree needs rebuilding, so the cost scales with the visible nodes, not with layout of the whole tree. Each recompute yields a new array instance, which the engine picks up automatically - no refresh() call and nothing to reset.

1. Flatten the visible subset of a real tree

Keep expand state in the model, never in the DOM: rows recycle - they unmount when they leave the window and remount on return, so a flag stored on an element is lost. A boolean on each node (or a Set of expanded ids in a store) survives recycling. The flatten walk pushes a node and, when it is expanded, recurses into its children - a pre-order traversal that stops at collapsed branches. Entries are the node objects themselves, so the #item slot receives the node and toggle() can mutate the reactive source directly.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

// Keep the real hierarchy in your data (API response, file walk, store, ...)
// and derive the flat list of *visible* rows from it.
export interface TreeNode {
  id: string;
  label: string;
  level: number;      // depth, root = 0: drives indentation and aria-level
  expanded: boolean;  // UI state lives in the model, never in the DOM
  children: TreeNode[];
}

// Depth-first flatten that stops at collapsed nodes. The result is the array
// VirtualScroll renders: expanding/collapsing only re-runs this walk, whose
// cost scales with the visible nodes, not with layout of the whole tree.
export function flattenVisible(nodes: TreeNode[], out: TreeNode[] = []): TreeNode[] {
  for (const node of nodes) {
    out.push(node);
    if (node.expanded && node.children.length > 0) {
      flattenVisible(node.children, out);
    }
  }
  return out;
}

// Tiny generator for the example - replace with your data source.
export function createTree(depth: number, breadth: number, prefix = 'node', level = 0): TreeNode[] {
  return Array.from({ length: breadth }, (_, i) => {
    const id = `${ prefix }-${ i }`;
    return {
      id,
      label: `Node ${ id }`,
      level,
      expanded: false,
      children: depth > 1 ? createTree(depth - 1, breadth, id, level + 1) : [],
    };
  });
}

2. Virtualize the flattened array with content-sized rows

Bind the visibleItems computed to :items and let rows size themselves: with no item-size, the engine measures every mounted row with a ResizeObserver, so a row can be exactly as tall as its label, twisty, and padding need. Rows that have not mounted yet are budgeted at default-item-size (default 40) and settle to their measured height the frame they mount. If every row of your tree genuinely has one fixed height, pass it as a numeric item-size instead and all positions become pure arithmetic - the smoothest option, but wrapped or taller content then overflows its row box.

<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import { computed, reactive } from 'vue';

import '@pdanpdan/virtual-scroll/style.css';

import { createTree, flattenVisible, type TreeNode } from './tree';

const tree = reactive(createTree(4, 4)); // four levels, four children each
const visibleItems = computed(() => flattenVisible(tree));

function toggle(node: TreeNode) {
  node.expanded = !node.expanded;
}
</script>

<template>
  <!-- item-role="none": the wrapper row is not the treeitem - the interactive
       row inside the slot is (it carries focus and the toggle handler). -->
  <VirtualScroll
    virtual-scrollbar
    class="tree"
    :items="visibleItems"
    role="tree"
    item-role="none"
    aria-label="Collapsible tree"
  >
    <!-- No item-size: rows are measured from rendered content, so each row is
         exactly as tall as its label needs. -->
    <template #item="{ item, index, getItemAriaProps }">
      <div
        role="treeitem"
        v-bind="getItemAriaProps(index)"
        tabindex="0"
        :aria-level="item.level + 1"
        :aria-expanded="item.children.length > 0 ? item.expanded : undefined"
        class="tree-row"
        :style="{ paddingInlineStart: `${ item.level * 20 + 12 }px` }"
        @click="toggle(item)"
        @keydown.enter="toggle(item)"
        @keydown.space.prevent="toggle(item)"
      >
        <span class="twisty" aria-hidden="true">{{ item.children.length > 0 ? (item.expanded ? '▾' : '▸') : '' }}</span>
        <span>{{ item.label }}</span>
      </div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.tree {
  height: 480px;
}
.tree-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  cursor: pointer;
  padding-block: 0.35rem;
}
.twisty {
  width: 1rem;
}
</style>

3. What expand and collapse do to the scroll math

Toggling a node inserts or removes its whole subtree between two neighbors, so every later index shifts. The engine watches the identity and length of :items: sizes are re-initialized per index - indices measured earlier keep their measurements, indices never mounted use the estimate - and the browser keeps its pixel scroll offset, which the engine re-reads after the DOM updates. The visible result matches a non-virtualized tree: content below the toggled node reflows, and collapsing rows above the viewport or shrinking the total below the current offset clamps to the new end. The engine does not re-anchor the viewport to the toggled node - after a toggle, look up the node's index in the new flattened array and call scrollToIndex() if you want to follow it.

Uniform rows make all of this invisible, because estimates equal measurements. With variable heights, a freshly mounted row may settle by a frame; reserving space for late-loading content keeps scrolling stable. Row rendering must stay idempotent and read only the model, since rows are recycled, and expand-all/collapse-all is the same recursion over the model with expanded set to a constant.

4. Keep the tree semantics for assistive technology

Pass role="tree" and the component maps item roles to treeitem. When the row content - not the wrapper - is the interactive element (it carries focus and the click handler), set item-role="none" and make the row root the treeitem yourself, binding getItemAriaProps(index) from the slot props for aria-setsize and aria-posinset. Add a 1-based aria-level, and aria-expanded only on nodes that have children. Make the row focusable (tabindex="0") and toggle on Enter and Space so the tree works without a pointer. The twisty is decorative: mark it aria-hidden and swap or rotate its glyph according to expanded.

Visible Nodes: 5
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y