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.
- src
- lib
- app.css
- package.json
- README.md
Basic Usage
Pass an items array where each node can carry its own children. Nodes without children render as leaves, with no chevron.
- src
- lib
- app.css
- package.json
- README.md
<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.
- src
- lib
- app.css
- package.json
- README.md
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
- Engineering
- Ana
- Bruno
- Chi
- Design
1 selected
propagateSelect + bubbleSelect
- Engineering
- Ana
- Bruno
- Chi
- Design
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.
- src
- lib
- app.css
- package.json
- README.md
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.
- src
- lib
- app.css
- package.json
- README.md
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.
- Engineering
- Ana
- Bruno
- Chi
- Design
<!-- 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.
- Public
- index.html
- robots.txt
- Private
<!-- 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.
- src
- lib
- app.css
- package.json
- README.md
<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.
- Inbox 12
- Starred 3
- Snoozed
- Archive 148
<!-- 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.
- Engineering
- Ana
- Bruno
- Chi
- Design
- Engineering
- Ana
- Bruno
- Chi
- Design
<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.
| Key | Action |
|---|---|
ArrowDown / ArrowUp | Move focus to the next or previous visible node |
ArrowRight | Expand a collapsed parent, or move into its first child |
ArrowLeft | Collapse an expanded parent, or move to the parent node |
Home / End | Move focus to the first or last visible node |
Enter / Space | Select the focused node |
* | Expand every sibling of the focused node |
A-Z | Typeahead: jump to the next node whose label starts with the typed characters |
TreeItem
Shape of each entry in the items array.
| Field | Type | Default |
|---|---|---|
label | string | - |
value | string | - |
icon | string | - |
trailingIcon | string | - |
children | TreeItem[] | - |
disabled | boolean | false |
defaultExpanded | boolean | false |
onToggle | (expanded: boolean) => void | - |
onSelect | (selected: boolean) => void | - |
class | string | - |
ui | Record<Slot, Class> | - |
TreeApi
Methods and readonly state exposed via bind:api.
| Member | Type |
|---|---|
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 |
expanded | readonly string[] |
selected | readonly TreeValue | undefined |
TreeSlotProps
Context passed to every item snippet.
| Property | Type |
|---|---|
item | T |
key | string |
index | number |
level | number |
expanded | boolean |
selected | boolean |
indeterminate | boolean |
hasChildren | boolean |
select | () => void |
toggle | () => void |
Snippets
| Snippet | Description |
|---|---|
item | Full custom row renderer. Replaces the leading icon, label and trailing chevron |
itemLeading | Content before the label. Falls back to the item or folder icon |
itemLabel | Label renderer. Falls back to item[labelKey] |
itemTrailing | Content after the label. Falls back to the expand chevron on parents |
UI Slots
Use the ui prop to override classes.
| Slot | Description |
|---|---|
root | The root ul element |
item | Each li node wrapper |
itemWithChildren | The li wrapper of a parent node |
listWithChildren | The nested ul holding the children of a parent node |
link | The interactive row of each node |
linkLeadingIcon | The leading icon |
linkLabel | The label text |
linkTrailing | The trailing container |
linkTrailingIcon | The expand/collapse chevron |
Props
| Prop | Type | Default |
|---|---|---|
items | TreeItem[] | [] |
value | string | string[] | - |
defaultValue | string | string[] | - |
onValueChange | (value) => void | - |
expanded | string[] | - |
defaultExpanded | string[] | - |
onExpandedChange | (expanded: string[]) => void | - |
api | TreeApi | - |
multiple | boolean | false |
selectionBehavior | 'toggle' | 'replace' | 'toggle' |
propagateSelect | boolean | false |
bubbleSelect | boolean | false |
getKey | (item, indexPath) => string | - |
labelKey | string | 'label' |
disabled | boolean | false |
color | ColorType | 'primary' |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' |
trailingIcon | string | 'lucide:chevron-down' |
expandedIcon | string | 'lucide:folder-open' |
collapsedIcon | string | 'lucide:folder' |
ref | HTMLUListElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |