Tree - headless
createTree drives styled SvTree plus a custom compact file explorer, one selected id with a readout.
A live, editable Svelte 5 component example from the SvGrid gallery (Headless Editors). Read the SvGrid UI component docs for the full API.
About this example
The headless tree core of the SvGrid UI kit in Svelte 5. createTree is the runes state machine behind SvTree: flattening the node tree into visible rows, expand and collapse, single selection, cascading tri-state checkboxes and the full keyboard, exposed as treeProps and itemProps. One core drives the styled SvTree and a custom compact file explorer, both bound to a single selected id.
Headless tree - the same headless-first split as the grid. createTree is the runes state machine behind <SvTree>: flattening, expand/collapse, single-select, cascading tri-state checkboxes and full keyboard - exposed as **prop-getters** you spread onto YOUR OWN markup. Below, one core drives the styled <SvTree> AND a custom compact explorer, both bound to a single selected id.
Imports, features and API used
Imports: @svgrid/grid
Frequently asked questions
What does flattening give my render?
An array of visible rows with depth, expanded state, selection and check state per node, so a render is a simple each block over rows rather than a recursive component.
What does itemProps wire?
role treeitem, aria-level, aria-expanded and aria-selected, the click and double-click handlers and the keyboard: Up and Down move, Right expands, Left collapses, Enter selects.
Do cascading checkboxes come from the core?
Yes. Checking a node updates its descendants and recomputes ancestors to checked, unchecked or partial, and both renders read the result.
Related documentation
Related articles
- SvGrid vs TanStack Table - A Deep Dive - A concrete architectural comparison of SvGrid and TanStack Table's Svelte adapter - how each handles reactivity, rendering, and feature composition, with code that shows exactly where they diverge.
- SvGrid vs svelte-headless-table - A practical comparison of SvGrid and svelte-headless-table covering reactivity model, rendering approach, feature scope, and when each is the right choice for a Svelte 5 project.
- SvGrid UI Components Tips and Tricks: Svelte 5 Buttons, Inputs, Tree, Forms - Practical tips for the SvGrid UI component kit - SvButton states, a Cmd+K command palette, a virtualized searchable tree, schema-driven forms, a parsing date/time field, and the shared field contract - each with a code snippet.
Source code (280-headless-tree.svelte)
<script lang="ts">
/**
* Headless tree - the same headless-first split as the grid. `createTree` is the
* runes state machine behind <SvTree>: flattening, expand/collapse, single-select,
* cascading tri-state checkboxes and full keyboard - exposed as **prop-getters**
* you spread onto YOUR OWN markup. Below, one core drives the styled <SvTree> AND
* a custom compact explorer, both bound to a single selected id.
*/
import { SvTree, createTree, type SvTreeNode } from '@svgrid/grid'
const nodes: SvTreeNode[] = [
{ id: 'src', label: 'src', children: [
{ id: 'comp', label: 'components', children: [
{ id: 'grid', label: 'SvGrid.svelte' },
{ id: 'tree', label: 'SvTree.svelte' },
] },
{ id: 'core', label: 'createTree.svelte.ts' },
] },
{ id: 'pkg', label: 'package.json' },
]
let selected = $state<string | null>('tree')
// The headless core - identical behavior, our own DOM.
const tree = createTree({
nodes: () => nodes,
selected: () => selected,
onSelect: (id) => (selected = id),
expandedIds: () => ['src', 'comp'],
})
// DOM focus movement is a render concern, so the renderer owns it.
let listEl: HTMLDivElement | null = null
let lastTick = 0
$effect(() => {
if (tree.focusTick !== lastTick) {
lastTick = tree.focusTick
const i = tree.activeIndex
queueMicrotask(() => listEl?.querySelector<HTMLElement>(`[data-row="${i}"]`)?.focus())
}
})
</script>
<div class="wrap">
<header>
<h2>Headless tree</h2>
<p>
Every editor ships a headless core + a styled component - the same split as the grid
(<code>createSvGrid</code> / <code><SvGrid></code>). Here <code>createTree</code>
drives both renders; flattening, expand/collapse and keyboard come from the core, the markup is yours.
</p>
</header>
<div class="cols">
<section>
<h3>Styled <code><SvTree></code></h3>
<SvTree {nodes} {selected} expandedIds={['src', 'comp']} onSelect={(id) => (selected = id)} ariaLabel="Files" />
</section>
<section>
<h3>Your markup, same core</h3>
<div class="exp" bind:this={listEl} {...tree.treeProps()}>
{#each tree.rows as row (row.node.id)}
<div class="exp__row" class:on={row.node.id === selected} style:padding-left={`${row.depth * 16 + 8}px`} {...tree.itemProps(row)}>
{#if row.hasChildren}
<button class="exp__tw" class:open={row.open} tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); tree.toggleExpand(row.node) }}>▸</button>
{:else}<span class="exp__dot">•</span>{/if}
<span class="exp__lbl">{row.node.label}</span>
</div>
{/each}
</div>
<p class="hint">Arrows move, left/right collapse/expand, Enter selects - all from <code>createTree</code>.</p>
</section>
</div>
<aside class="readout">
<h3>Selected</h3>
{#if selected}<div class="tags"><span class="tag">{selected}</span></div>{:else}<p class="empty">Nothing selected</p>{/if}
</aside>
</div>
<style>
.wrap { padding: 20px; max-width: 900px; display: flex; flex-direction: column; gap: 18px; }
header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }
header p { margin: 0; color: var(--sg-muted, #64748b); font-size: 13.5px; line-height: 1.55; max-width: 680px; }
code { background: var(--sg-row-hover-bg, #eef2ff); padding: 1px 5px; border-radius: 5px; font-size: 12px; }
.cols { display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; }
section h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
/* Fully custom tree render - nothing shared with SvTree but the core. */
.exp { width: 240px; padding: 6px; border-radius: 10px; background: var(--sg-input-bg, #fff); border: 1px dashed var(--sg-border, #e2e8f0); outline: none; font-size: 13px; }
.exp__row { display: flex; align-items: center; gap: 6px; height: 28px; border-radius: 6px; cursor: pointer; outline: none; }
.exp__row:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
.exp__row.on { background: color-mix(in srgb, var(--sg-accent, #4f46e5) 16%, transparent); color: var(--sg-accent, #4f46e5); font-weight: 600; }
.exp__row:focus-visible { box-shadow: inset 0 0 0 2px var(--sg-accent, #4f46e5); }
.exp__tw { border: 0; background: none; cursor: pointer; color: var(--sg-muted, #64748b); width: 16px; transition: transform 0.12s; }
.exp__tw.open { transform: rotate(90deg); }
.exp__dot { width: 16px; text-align: center; color: var(--sg-muted, #94a3b8); }
.exp__lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hint { margin: 10px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); line-height: 1.5; }
.readout h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { padding: 3px 10px; border-radius: 6px; font-size: 13px; background: var(--sg-row-hover-bg, #f1f5f9); }
.empty { color: var(--sg-muted, #94a3b8); font-style: italic; margin: 0; }
</style>More Headless Editors examples
- Headless editors - Headless-first, like the grid: createListbox is the state machine behind SvListBox (roving focus, single/multi selection, keyboard, ARIA) exposed as prop-getters you spread onto YOUR own markup. One core drives both the styled SvListBox and a custom chip-cloud render, bound to one value.
- Combobox - headless - createCombobox drives the styled SvComboBox and a custom filter-input render, both bound to one value with a readout.
- Dropdown list - headless - createDropdownList drives SvDropDownList and a custom trigger+menu render, sharing one value.
- Autocomplete - headless - createAutocomplete drives SvAutoComplete and a custom suggestion-list render over one text value.
- Tags input - headless - createTagsInput drives SvTagsInput and a custom chip-cloud render, both editing one string array.