Data Display

Tree

An accessible hierarchical tree view built on the WAI-ARIA tree pattern, with expand and collapse, single or multiple selection, optional parent-to-child propagation, and full keyboard navigation.

Playground

Experiment with different props in real-time.

Basic Usage

Pass an items array where each node can carry its own children. Nodes without children render as leaves, with no chevron.

<script lang="ts">
  import { Tree } from 'sv5ui';
  import type { TreeItem } from 'sv5ui';

  const files: TreeItem[] = [
    {
      label: 'src',
      defaultExpanded: true,
      children: [
        {
          label: 'lib',
          children: [
            { label: 'index.ts', icon: 'lucide:file-code' },
            { label: 'utils.ts', icon: 'lucide:file-code' }
          ]
        },
        { label: 'app.css', icon: 'lucide:file-type' }
      ]
    },
    { label: 'package.json', icon: 'lucide:file-json' },
    { label: 'README.md', icon: 'lucide:file-text' }
  ];
</script>

<Tree items={files} />

Node Keys

Every node needs a key unique across the whole tree. It is resolved as getKey(), then item.value, then the label, then the positional index path such as 0.2.1. If a resolved key collides with an earlier node, the later node silently falls back to its index path, so pass an explicit value whenever labels can repeat.

Two files named index.ts in different folders need distinct value keys, otherwise selecting one highlights both.

<!-- Keys are resolved in this order:
     getKey(item, indexPath) -> item.value -> item[labelKey] -> index path

     Duplicate labels are common in a tree ("index.ts" in two folders),
     so give colliding nodes an explicit value, or pass getKey. -->

<Tree
  items={files}
  getKey={(item, indexPath) => item.path ?? indexPath}
/>

<!-- Or per node -->
const files: TreeItem[] = [
  { label: 'src', value: 'src', children: [
    { label: 'index.ts', value: 'src/index.ts' }
  ] },
  { label: 'tests', value: 'tests', children: [
    { label: 'index.ts', value: 'tests/index.ts' }
  ] }
];

Multiple Selection

Set multiple to collect several keys. The bound value becomes a string[] and the root gets aria-multiselectable.

Selected: src/lib/index.ts

<script lang="ts">
  let selected = $state<string[]>(['src/index.ts']);
</script>

<!-- multiple turns value into a string[] and sets
     aria-multiselectable on the root -->
<Tree items={files} multiple bind:value={selected} />

<!-- selectionBehavior="replace" makes each click the only
     selection, even with multiple enabled -->
<Tree items={files} multiple selectionBehavior="replace" />

Propagate and Bubble

propagateSelect pushes a parent's selection down to every selectable descendant and marks partially selected parents as indeterminate. bubbleSelect works the other way: a parent joins the selection on its own once all of its children are selected.

propagateSelect

1 selected

propagateSelect + bubbleSelect

0 selected

<!-- propagateSelect: selecting a parent selects every
     selectable descendant. Partially selected parents
     report indeterminate (a dash instead of a check). -->
<Tree items={files} multiple propagateSelect bind:value={selected} />

<!-- bubbleSelect: a parent joins the selection automatically
     once all of its selectable children are selected, and
     leaves it as soon as one is deselected. -->
<Tree items={files} multiple propagateSelect bubbleSelect bind:value={selected} />

Expanded State

Mark nodes with defaultExpanded for the uncontrolled case, or drive the tree with bind:expanded. Passing expanded at all, even an empty array, makes every per-node defaultExpanded inert.

Expanded keys: src

<script lang="ts">
  // Uncontrolled: nodes marked defaultExpanded start open
  const files: TreeItem[] = [
    { label: 'src', defaultExpanded: true, children: [...] }
  ];

  // Controlled: bind:expanded takes over completely.
  // Passing expanded (even []) ignores every defaultExpanded.
  let expanded = $state<string[]>(['src']);
</script>

<Tree
  items={files}
  bind:expanded
  onExpandedChange={(keys) => console.log(keys)}
/>

Imperative API

Bind api to drive the tree from outside: expand-all toolbars, programmatic selection, or moving focus after a search.

Selected: none

<script lang="ts">
  import { Tree, Button } from 'sv5ui';
  import type { TreeApi } from 'sv5ui';

  let api = $state<TreeApi>();
</script>

<div class="flex gap-2">
  <Button label="Expand all" onclick={() => api?.expandAll()} />
  <Button label="Collapse all" onclick={() => api?.collapseAll()} />
  <Button label="Clear" onclick={() => api?.clearSelection()} />
</div>

<Tree bind:api items={files} multiple />

<!-- api also exposes: expand, collapse, toggle, select, deselect,
     focus, isExpanded, isSelected, and the readonly
     expanded / selected values. -->

Icons

Parent nodes without an explicit item.icon fall back to expandedIcon and collapsedIcon. Per-node icons always win.

<!-- Parent nodes without an explicit item.icon fall back to the
     folder icons. Leaves fall back to nothing. -->
<Tree
  items={files}
  expandedIcon="lucide:folder-open"
  collapsedIcon="lucide:folder"
  trailingIcon="lucide:chevron-down"
/>

<!-- Per node overrides win over the tree-level defaults -->
const items: TreeItem[] = [
  { label: 'Components', icon: 'lucide:box', children: [...] },
  { label: 'Settings', icon: 'lucide:settings', trailingIcon: 'lucide:plus' }
];

Disabled

A disabled node cannot be selected, expanded, or reached by keyboard, and its children stay unreachable. Pass disabled on the tree to freeze the whole thing.

<!-- A disabled node cannot be selected, expanded, or reached
     by keyboard. Its children stay unreachable too, and
     propagateSelect skips them. -->
const items: TreeItem[] = [
  { label: 'Public', children: [{ label: 'index.html' }] },
  { label: 'Private', disabled: true, children: [{ label: 'secrets.env' }] }
];

<Tree {items} />

<!-- Disable the whole tree -->
<Tree {items} disabled />

Events

Listen at the tree level with onValueChange and onExpandedChange, or per node with onSelect and onToggle.

Select or expand a node.
<Tree
  items={files}
  onValueChange={(value) => console.log('selection', value)}
  onExpandedChange={(keys) => console.log('expanded', keys)}
/>

<!-- Or per node -->
const items: TreeItem[] = [
  {
    label: 'src',
    onToggle: (expanded) => console.log('src expanded:', expanded),
    onSelect: (selected) => console.log('src selected:', selected)
  }
];

Custom Rendering

Four snippets cover the row: item replaces it entirely, while itemLeading, itemLabel and itemTrailing replace one region each. Svelte has no dynamic named slots, so branch on the item inside the shared snippet.

<!-- itemTrailing replaces the default chevron slot content.
     Every item snippet receives the same TreeSlotProps. -->
<Tree items={files} multiple>
  {#snippet itemTrailing({ item, hasChildren, expanded, selected })}
    {#if item.count}
      <Badge label={String(item.count)} size="sm" variant="soft" />
    {/if}
    {#if hasChildren}
      <Icon name="lucide:chevron-down" class="transition-transform {expanded ? 'rotate-180' : ''}" />
    {/if}
  {/snippet}
</Tree>

<!-- item replaces the whole row (leading icon, label and trailing).
     Children still render below it. -->
<Tree items={files}>
  {#snippet item({ item, level, selected, select, toggle })}
    <button onclick={select} ondblclick={toggle}>
      {'-'.repeat(level)} {item.label}
    </button>
  {/snippet}
</Tree>

Appearance

Use color and size for the built-in variants, and ui for slot-level classes. Individual nodes accept their own class and ui.

<Tree items={files} color="success" size="lg" />

<!-- Slot classes -->
<Tree
  items={files}
  ui={{ link: 'font-mono', linkLeadingIcon: 'text-primary' }}
/>

<!-- Per node slot overrides -->
const items: TreeItem[] = [
  { label: 'Deprecated', ui: { link: 'line-through opacity-60' } }
];

Keyboard Navigation

The tree exposes a single tab stop and moves focus with the arrow keys, following the WAI-ARIA tree pattern.

KeyAction
ArrowDown / ArrowUpMove focus to the next or previous visible node
ArrowRightExpand a collapsed parent, or move into its first child
ArrowLeftCollapse an expanded parent, or move to the parent node
Home / EndMove focus to the first or last visible node
Enter / SpaceSelect the focused node
*Expand every sibling of the focused node
A-ZTypeahead: jump to the next node whose label starts with the typed characters

TreeItem

Shape of each entry in the items array.

FieldTypeDefault
labelstring-
valuestring-
iconstring-
trailingIconstring-
childrenTreeItem[]-
disabledbooleanfalse
defaultExpandedbooleanfalse
onToggle(expanded: boolean) => void-
onSelect(selected: boolean) => void-
classstring-
uiRecord<Slot, Class>-

TreeApi

Methods and readonly state exposed via bind:api.

MemberType
expand / collapse / toggle(key: string) => void
expandAll / collapseAll() => void
select / deselect(key: string) => void
clearSelection() => void
focus(key: string) => void
isExpanded / isSelected(key: string) => boolean
expandedreadonly string[]
selectedreadonly TreeValue | undefined

TreeSlotProps

Context passed to every item snippet.

PropertyType
itemT
keystring
indexnumber
levelnumber
expandedboolean
selectedboolean
indeterminateboolean
hasChildrenboolean
select() => void
toggle() => void

Snippets

SnippetDescription
itemFull custom row renderer. Replaces the leading icon, label and trailing chevron
itemLeadingContent before the label. Falls back to the item or folder icon
itemLabelLabel renderer. Falls back to item[labelKey]
itemTrailingContent after the label. Falls back to the expand chevron on parents

UI Slots

Use the ui prop to override classes.

SlotDescription
rootThe root ul element
itemEach li node wrapper
itemWithChildrenThe li wrapper of a parent node
listWithChildrenThe nested ul holding the children of a parent node
linkThe interactive row of each node
linkLeadingIconThe leading icon
linkLabelThe label text
linkTrailingThe trailing container
linkTrailingIconThe expand/collapse chevron

Props

PropTypeDefault
itemsTreeItem[][]
valuestring | string[]-
defaultValuestring | string[]-
onValueChange(value) => void-
expandedstring[]-
defaultExpandedstring[]-
onExpandedChange(expanded: string[]) => void-
apiTreeApi-
multiplebooleanfalse
selectionBehavior'toggle' | 'replace''toggle'
propagateSelectbooleanfalse
bubbleSelectbooleanfalse
getKey(item, indexPath) => string-
labelKeystring'label'
disabledbooleanfalse
colorColorType'primary'
size'xs' | 'sm' | 'md' | 'lg' | 'xl''md'
trailingIconstring'lucide:chevron-down'
expandedIconstring'lucide:folder-open'
collapsedIconstring'lucide:folder'
refHTMLUListElement | nullnull
classstring-
uiRecord<Slot, Class>-