Skip to content

Tree

import { Tree } from '@delightstack/components';
import type { TreeNode, FlatTreeNode } from '@delightstack/components';
  • src
    • components
      • Button.svelte
      • Modal.svelte
      • Table.svelte
    • app.ts
    • index.ts
  • package.json
  • tsconfig.json
  • README.md
View code
<script>
import { Tree } from '@delightstack/components';
const data = [
{
id: 'src',
label: 'src',
children: [
{
id: 'components',
label: 'components',
children: [
{ id: 'button', label: 'Button.svelte' },
{ id: 'modal', label: 'Modal.svelte' },
{ id: 'table', label: 'Table.svelte' },
],
},
{
id: 'utils',
label: 'utils',
children: [
{ id: 'format', label: 'format.ts' },
{ id: 'validate', label: 'validate.ts' },
],
},
{ id: 'app', label: 'app.ts' },
{ id: 'index', label: 'index.ts' },
],
},
{ id: 'package', label: 'package.json' },
{ id: 'tsconfig', label: 'tsconfig.json' },
{ id: 'readme', label: 'README.md' },
];
let expanded = $state(['src', 'components']);
</script>
<Tree {data} bind:expanded show_lines />
  • src
    • components
      • Button.svelte
      • Modal.svelte
    • app.ts
  • package.json
  • README.md

Click a file to select it. Folders expand/collapse on click.

View code
<script>
import { Tree } from '@delightstack/components';
const fileTree = [
{
id: '1',
label: 'src',
children: [
{
id: '1-1',
label: 'components',
children: [
{ id: '1-1-1', label: 'Button.svelte' },
{ id: '1-1-2', label: 'Modal.svelte' },
],
},
{ id: '1-2', label: 'app.ts' },
],
},
{ id: '2', label: 'package.json' },
];
let selected = $state([]);
let expanded = $state(['1']);
</script>
<Tree
data={fileTree}
selectable
bind:selected
bind:expanded
/>

Parent checkboxes reflect children state with an indeterminate state when partially selected. Checking a parent checks all children.

  • Fruits
    • Apple
    • Banana
    • Cherry
  • Vegetables
    • Carrot
    • Broccoli
    • Spinach
View code
<script>
import { Tree } from '@delightstack/components';
const categories = [
{
id: 'fruits',
label: 'Fruits',
children: [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{ id: 'cherry', label: 'Cherry' },
],
},
{
id: 'vegetables',
label: 'Vegetables',
children: [
{ id: 'carrot', label: 'Carrot' },
{ id: 'broccoli', label: 'Broccoli' },
{ id: 'spinach', label: 'Spinach' },
],
},
];
let selected = $state([]);
let expanded = $state(['fruits', 'vegetables']);
</script>
<Tree data={categories} checkboxes multi_select bind:selected bind:expanded />

The component automatically detects flat data (arrays with parentId properties) and builds the tree structure internally.

  • Root
    • Child 1
      • Grandchild 1
      • Grandchild 2
View code
<Tree
data={[
{ id: '1', parentId: null, label: 'Root' },
{ id: '2', parentId: '1', label: 'Child 1' },
{ id: '3', parentId: '1', label: 'Child 2' },
{ id: '4', parentId: '2', label: 'Grandchild 1' },
{ id: '5', parentId: '2', label: 'Grandchild 2' },
{ id: '6', parentId: '3', label: 'Grandchild 3' },
]}
selectable
/>

Nodes matching the filter term are highlighted. Non-matching branches without matching descendants are hidden, and parent nodes auto-expand to reveal matches.

  • package.json
  • README.md
View code
<script>
import { Tree } from '@delightstack/components';
let searchTerm = $state('');
</script>
<input bind:value={searchTerm} placeholder="Search..." />
<Tree data={fileTree} filter={searchTerm} />

Enable drag-and-drop reordering between nodes.

  • Documents
    • report.pdf
    • notes.txt
  • Images
    • photo1.png
    • photo2.jpg
  • README.md
View code
<Tree
data={fileTree}
draggable
ondrop={({ node, target, position }) => {
updateTree(node, target, position);
}}
/>

Load children on demand when a node is expanded. A loading spinner replaces the chevron during the fetch.

View code
<Tree
data={rootNodes}
load_children={async (node) => {
return await fetchChildren(node.id);
}}
/>
  • src
    • lib
      • auth.ts
      • db.ts
    • routes
      • +page.svelte
      • about/+page.svelte
    • app.html
  • static
    • favicon.png
  • package.json
View code
<Tree data={fileTree} show_lines bind:expanded />
  • src 4 files
    • index.ts entry
    • app.ts
    • config.ts modified
    • utils.ts
  • package.json v2.0.0
  • README.md
View code
{#snippet customNode({ node, level })}
<span>{node.label}</span>
{#if node.data?.badge}
<span class="badge">{node.data.badge}</span>
{/if}
{/snippet}
<Tree data={fileTree} node_content={customNode} />

Use dense for compact node spacing or comfortable for relaxed spacing. The default sits between the two.

Dense
  • src
    • components
      • Button.svelte
      • Modal.svelte
    • app.ts
    • index.ts
  • package.json
  • README.md
Comfortable
  • src
    • components
      • Button.svelte
      • Modal.svelte
    • app.ts
    • index.ts
  • package.json
  • README.md
View code
<Tree data={fileTree} dense show_lines />
<Tree data={fileTree} comfortable show_lines />
View code
<Tree skeleton={loading} skeleton_count={7} skeleton_depth={3} data={loading ? [] : data} />
PropTypeDefaultDescription
dataTreeNode[] | FlatTreeNode[]requiredTree data (nested or flat with parent IDs)
selectedstring[][]Selected node IDs (bindable)
expandedstring[][]Expanded node IDs (bindable)
focusedstring | nullnullThe node the keyboard cursor is on (bindable); null when the tree is not focused
selectablebooleanfalseEnable node selection
multi_selectbooleanfalseAllow multiple selection
checkboxesbooleanfalseShow checkboxes for selection
show_linesbooleanfalseShow connecting lines between nodes
draggablebooleanfalseEnable drag-and-drop reordering
filterstring-Search/filter term to highlight and filter matches
densebooleanfalseCompact node spacing
comfortablebooleanfalseRelaxed node spacing
skeletonbooleanfalseShow loading skeleton
skeleton_countnumber5Number of skeleton nodes
skeleton_depthnumber2Nesting depth of skeleton nodes
idstringautoElement ID
classstring''Additional CSS classes
load_children(node: TreeNode) => Promise<TreeNode[]>-Lazy load children on expand
node_contentSnippet<[{ node: TreeNode; level: number }]>-Custom node content renderer
EventDetailDescription
onselect{ node: TreeNode, selected: string[] }Selection changed
onexpand{ node: TreeNode, expanded: boolean }Node expanded or collapsed
ondrop{ node: TreeNode, target: TreeNode, position: 'before' | 'after' | 'inside' }Node dropped after drag

The nested tree data format:

interface TreeNode {
id: string;
label: string;
icon?: Component;
children?: TreeNode[];
disabled?: boolean;
data?: unknown;
}

The flat data format with parent references:

interface FlatTreeNode {
id: string;
parentId: string | null;
label: string;
icon?: Component;
disabled?: boolean;
data?: unknown;
}

A collapsed branch is not rendered. The tree puts a branch’s children in the document when it opens and takes them out again once the closing animation has finished, so the number of elements on the page follows what is open rather than how much data was passed in — a 10,000-node tree with everything collapsed is twenty rows of DOM, not ten thousand.

That is invisible from the outside: expanded, selected and focused still name any node in data, keyboard navigation still walks the whole visible order, and filter still searches every node whether or not its branch is open. What it does mean is that document.querySelector only finds rows inside open branches — code reaching into the tree’s DOM should read the node data instead.

KeyAction
Arrow DownMove focus to next visible node
Arrow UpMove focus to previous visible node
Arrow RightExpand focused node, or move to first child if already expanded
Arrow LeftCollapse focused node, or move to parent if already collapsed
Enter / SpaceSelect or toggle checkbox on focused node
HomeMove focus to first visible node
EndMove focus to last visible node
* (asterisk)Expand all siblings of the focused node
Shift + Arrow Up/DownExtend the selection one row, taking the cursor with it (multi_select)
Mod + ASelect every visible node (multi_select)
EscapeClear the selection — claimed only when there is one to clear (multi_select)
Printable charactersType-ahead — jumps to the next node whose label starts with what you typed. The buffer resets after 700ms; repeating one character cycles through the nodes starting with it

With multi_select, clicking follows the same rules as the pointer half: mod-click toggles one node, shift-click takes the range from the anchor, and mod+shift-click keeps the rest. The anchor stays where the last plain or mod-click put it, so a second, closer shift-click narrows the range rather than ratcheting it outward. The rules live in the exported selection model (applySelection, extendSelection, selectionGestureOf), which List and Table can adopt through the same functions.

Keystrokes that originate inside an <input>, <textarea>, <select> or a contenteditable element — an inline rename rendered by node_content, say — are left alone, so the control keeps its own arrows, Home/End and Enter.

  • Container has role="tree", each node has role="treeitem"
  • aria-expanded on expandable nodes
  • aria-selected on selectable nodes
  • aria-level indicating nesting depth
  • aria-activedescendant for focus management
  • aria-multiselectable when multiple selection is enabled
  • aria-disabled on disabled nodes
  • Full keyboard navigation with arrow keys, Home, End, Enter, Space
  • Expand/collapse animations respect prefers-reduced-motion
  • Search matches are highlighted with <mark> elements