TreeView

Hierarchical tree with expand/collapse, roving-tabindex keyboard navigation, and tri-state checkbox cascade.

Live demo

01

File explorer

A plain tree. Click a folder to toggle it, or focus a row and use the arrow keys: Right opens / descends, Left collapses / ascends, Up/Down move, Home/End jump.

  • README.md
  • package.json

Fully expanded on mount

Pass defaultExpandAll to open every branch on first render.

  • README.md
  • package.json

Tri-state checkboxes

With checkboxes, ticking a branch checks every leaf beneath it; ticking some children leaves the parent indeterminate. Selected leaves: apple Β· Last event: β€”

Implementation

02
TreeView.svelte
<script lang="ts">
  import TreeView, { type TreeNode } from '$lib/components/TreeView.svelte';
​
  const nodes: TreeNode[] = [
    { id: 'src', label: 'src', children: [
      { id: 'lib', label: 'lib', children: [{ id: 'utils', label: 'utils.ts' }] },
      { id: 'app', label: 'app.html' }
    ] },
    { id: 'readme', label: 'README.md' }
  ];
  let selected = $state(new Set<string>(['utils']));
</script>
​
<TreeView {nodes} checkboxes bind:selected defaultExpandAll label="Project files" />

TreeView follows the WAI-ARIA tree pattern: a role=tree container, role=treeitem rows, and role=group child lists, with a single roving tabindex so the whole widget is one Tab stop. Rendering is a self-referencing Svelte 5 snippet, so arbitrary depth needs no child component. The keyboard model walks a derived "visible order" flattening (depth-first, skipping collapsed subtrees) so Up/Down are a single index step. Tri-state checkboxes store only checked leaf ids; every branch state β€” checked, unchecked, indeterminate β€” is derived from its descendants, so parent and child can never drift apart.

Logic explainer

03

What Does It Do? (Plain English)

TreeView renders nested data β€” a file system, a category taxonomy, an org chart β€” as an indented, collapsible tree. Each branch can be opened or closed, and the whole widget behaves like a single control: you Tab into it once, then drive everything from the keyboard. Turn on checkboxes and every branch shows a tri-state box that fills in when all its children are ticked, half-fills when only some are, and clears when none are.

Think of it as the left-hand pane of a file explorer: folders twist open, files sit at the leaves, and ticking a folder ticks everything inside it.

How It Works (Pseudo-Code)

state:
  expanded  = Set<id>      // which branches are open
  selected  = Set<leafId>  // ONLY checked leaves are stored
  activeId  = id | null    // the single roving tab stop

derived:
  visible = flatten(nodes, expanded)   // depth-first, skip closed subtrees

events:
  on click row:
    activeId = node.id
    if node has children: toggleExpand(node)
    else if checkboxes:   toggleCheck(node)

  on keydown:
    ArrowDown -> focus next visible row
    ArrowUp   -> focus previous visible row
    ArrowRight-> if closed branch: expand
                 else if open branch: focus first child
    ArrowLeft -> if open branch: collapse
                 else: focus parent
    Home/End  -> focus first / last visible row
    Enter/Spc -> checkboxes ? toggleCheck : toggleExpand

The Core Concept: Derived Tri-State, Not Stored

The trap with tri-state checkboxes is storing a checked flag on every node β€” parent and child state then drift apart and you spend the rest of the project re-syncing them. TreeView refuses that. The selected Set holds only checked leaf ids. Every branch's appearance is computed on demand:

checkStateOf(branch):
  leaves = all leaf descendants of branch
  n      = how many of those leaves are in `selected`
  if n == 0            -> 'unchecked'
  if n == leaves.count -> 'checked'
  else                 -> 'indeterminate'

Toggling a branch is symmetric: if it is fully or partly checked, remove all its leaves from the Set; if it is empty, add them all. Because parent state is never written down, it can never disagree with its children β€” there is one source of truth.

        [ ] Documents            indeterminate  (1 of 3 leaves)
         β”œ [x] cv.pdf            in `selected`
         β”œ [ ] notes.md
         β”” [ ] budget.xlsx

Recursive Snippet Rendering

Arbitrary depth needs a renderer that calls itself. Svelte 5 snippets can do exactly that β€” branch renders a <ul role="group"> of rows and, for any open branch, calls {@render branch(node.children, level + 1, node.id)}. No separate child component, no prop-drilling: one snippet, one set of styles, any depth.

The visible-order keyboard model is kept separate from rendering. A flatten() pass walks the tree depth-first, skipping collapsed subtrees, producing the exact row order the user sees. Up/Down then become a single index step in that flat array, and Right/Left map cleanly onto expand/descend and collapse/ascend.

State Flow Diagram

                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚   COLLAPSED leaf  β”‚
                 β”‚  (no children)    β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       Enter/Space/click   β”‚ (checkboxes) toggles its own leaf id
                           β–Ό
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        ArrowRight β”‚      BRANCH      β”‚ ArrowLeft
        (expand) ◀──  expanded? open  β”œβ”€β–Ά (collapse)
                 β”‚  : closed         β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
              ArrowRight on β”‚ open branch
              (descend)     β–Ό
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚   first child row β”‚  ← activeId moves, roving tabindex follows
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  Any collapse that hides activeId  ->  $effect re-homes activeId to the first
                                        still-visible row.

Props Reference

Prop Type Default Description
nodes TreeNode[] [] Hierarchical data: { id, label, children? }
checkboxes boolean false Show tri-state checkboxes that cascade and bubble
expanded Set<string> (bindable) new Set() IDs of currently open branches
selected Set<string> (bindable) new Set() IDs of checked leaf nodes (parents are derived)
defaultExpandAll boolean false Expand every branch on first render
label string 'Tree' aria-label for the tree container
onselect (id: string, state: boolean) => void undefined Fired when a node is activated (toggle/check)

Edge Cases

Situation Behaviour
nodes empty Renders an italic "No items to display." message
Leaf node No twisty rendered; aria-expanded omitted; Enter checks it (if checkboxes)
Branch with children: [] Treated as a leaf β€” no twisty, no expansion
Collapse hides the active node An $effect re-homes activeId to the first visible row
ArrowRight on an open branch Moves focus to the first child rather than re-expanding
ArrowLeft on a top-level leaf No-op (no parent to ascend to)
Ticking a branch that is partly checked Clears all its leaves (matches native folder-checkbox behaviour)
Duplicate ids Selection/expansion keyed by id will collide β€” ids must be unique
Ids containing ", \, or ] Escaped before the focus querySelector; no library needed

Dependencies

Zero external dependencies. Pure Svelte 5 runes ($state, $derived, $effect, $props, $bindable), a self-referencing snippet, inline SVG icons, and scoped CSS. No icon library, no state-management library.

File Structure

TreeView.svelte    # Component (module-block exports the TreeNode interface)
TreeView.md        # This explainer
TreeView.test.ts   # Vitest + @testing-library/svelte behaviour tests

API

04
PropTypeDefaultDescription
nodesTreeNode[][]Hierarchical data. Each node is { id, label, children? }.
checkboxesbooleanfalseShow tri-state checkboxes that cascade down and bubble up.
expandedSet<string> (bindable)new Set()IDs of currently open branches.
selectedSet<string> (bindable)new Set()IDs of checked leaf nodes; parent state is derived.
defaultExpandAllbooleanfalseExpand every branch on first render.
labelstring'Tree'Accessible name (aria-label) for the tree.
onselect(id, state) => voidβ€”Fires when a node is activated (toggle or check).