# Tree rows (expand / collapse)
SvGrid does not have a treeData prop. Tree-shaped data renders through
the same data + columns pipeline as any other grid, with the tree
behaviour living in your derived-state code. This is on purpose: a
tree is just "a flat list with a depth field and a collapsible
subtree", and the headless engine doesn't need to know which.
Try the org-chart pattern - click any chevron to expand a branch, or focus a name cell and press Right / Left / Enter:
What it is
A "tree row" is any row with a depth: number, a childIds: string[]
(or equivalent), and a parent reference. Whether a row is currently
visible depends on its ancestors' expanded state.
The pattern
Three pieces of state, the third one derived:
let allRows = $state<Node[]>(/* every node, flat */)
let expanded = $state<Record<string, boolean>>({ root: true })
const visibleRows = $derived.by(() => {
const out: Node[] = []
const byId = new Map(allRows.map((n) => [n.id, n]))
function walk(id: string) {
const node = byId.get(id)
if (!node) return
out.push(node)
if (expanded[id]) for (const cid of node.childIds) walk(cid)
}
for (const root of allRows.filter((n) => n.parentId === null)) walk(root.id)
return out
})
Hand visibleRows to <SvGrid data={visibleRows} ...> like any
other dataset. The grid stays unaware that the data is hierarchical.
The expand-chevron cell
Render the chevron + indentation as part of a custom cell snippet on the leftmost (or "name") column:
{#snippet NameCell(props: { node: Node })}
{@const canExpand = props.node.childIds.length > 0}
{@const isOpen = !!expanded[props.node.id]}
<span class="tree-name" style="padding-left: {props.node.depth * 22}px">
{#if canExpand}
<button
type="button"
class={`tree-chev ${isOpen ? 'tree-chev-open' : ''}`}
onclick={() => (expanded = { ...expanded, [props.node.id]: !isOpen })}
aria-expanded={isOpen}
aria-label={isOpen ? 'Collapse' : 'Expand'}
>
<svg viewBox="0 0 16 16" width="10" height="10"
fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="5 3 11 8 5 13" />
</svg>
</button>
{/if}
<span>{props.node.name}</span>
</span>
{/snippet}
The corresponding CSS rotates the SVG instead of swapping a character, which animates smoothly:
.tree-chev {
transition: transform 160ms ease;
}
.tree-chev-open { transform: rotate(90deg); }
Tree connector lines
For visual continuity between parents and children, draw guide lines in absolute position inside the name cell. One vertical guide per ancestor depth, plus a short horizontal "elbow" into the current row:
<span class="tree-name" style="position: relative; padding-left: {4 + node.depth * 22}px">
{#each Array(node.depth) as _, i (i)}
<span class="tree-guide" style="left: {4 + i * 22 + 11}px"></span>
{/each}
{#if node.depth > 0}
<span class="tree-elbow" style="left: {4 + (node.depth - 1) * 22 + 11}px"></span>
{/if}
...
</span>
.tree-guide {
position: absolute;
top: 0; bottom: 0;
border-left: 1px dashed rgba(148, 163, 184, 0.35);
}
.tree-elbow {
position: absolute;
top: 50%;
width: 14px;
border-top: 1px dashed rgba(148, 163, 184, 0.45);
}
Keyboard navigation
The grid's built-in arrow-key handling moves the active cell between columns. To get standard tree-grid keys (Right expands, Left collapses, Enter toggles), intercept at the window level with a capture listener so your handler runs before the grid's:
let activeCol = $state<string>('')
let activeRowIndex = $state<number>(0)
$effect(() => {
function onKey(e: KeyboardEvent) {
if (activeCol !== 'name') return // not on the tree column
const node = visibleRows[activeRowIndex]
if (!node || node.childIds.length === 0) return // leaves don't toggle
const isOpen = !!expanded[node.id]
if (e.key === 'ArrowRight' && !isOpen) {
e.preventDefault()
expanded = { ...expanded, [node.id]: true }
} else if (e.key === 'ArrowLeft' && isOpen) {
e.preventDefault()
expanded = { ...expanded, [node.id]: false }
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
expanded = { ...expanded, [node.id]: !isOpen }
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
})
Wire SvGrid to track the active cell:
<SvGrid
data={visibleRows}
columns={columns}
onActiveCellChange={(args) => {
activeCol = args.columnId
activeRowIndex = args.rowIndex
}}
...
/>
This pattern is non-invasive: regular arrow keys still move the active cell between non-name columns; tree keys only fire when the user is focused on the tree column.
Roll-ups (computed values at non-leaf rows)
When a parent's value is a roll-up of its children (headcount, percent-complete, cost), put the computation in a separate function that runs after every leaf edit and writes the result back onto the parent rows:
function recompute(rows: Node[]): Node[] {
const byId = new Map(rows.map((r) => [r.id, { ...r }]))
// post-order DFS: deepest first so each parent already has updated children
const ordered = [...byId.values()].sort((a, b) => b.depth - a.depth)
for (const r of ordered) {
if (r.childIds.length === 0) continue
let sum = 0
for (const cid of r.childIds) sum += byId.get(cid)!.subtotal
r.subtotal = sum
}
return rows.map((r) => byId.get(r.id)!)
}
Hook it from onCellValueChange:
<SvGrid
...
onCellValueChange={(e) => {
const next = allRows.slice()
const ix = next.findIndex((r) => r.id === e.row.id)
next[ix] = { ...next[ix], [e.columnId]: e.newValue }
allRows = recompute(next)
}}
/>
Lazy load on first expand
For trees that are too large to seed up front, fetch children only when the user expands a node:
async function toggle(id: string) {
const isOpen = !!expanded[id]
expanded = { ...expanded, [id]: !isOpen }
if (isOpen) return // collapsing - no fetch needed
const node = allNodes.find((n) => n.id === id)
if (!node || !node.expandable || node.loadState === 'loaded') return
allNodes = allNodes.map((n) => (n.id === id ? { ...n, loadState: 'loading' } : n))
const children = await fetchChildren(id)
allNodes = allNodes
.map((n) => n.id === id ? { ...n, loadState: 'loaded', childIds: children.map((c) => c.id) } : n)
.concat(children)
}
Render a placeholder spinner row in visibleRows while loadState === 'loading'.
See also
- Row data - the underlying
dataprop and accessors. - Row sorting - applies to tree rows too, but you
control the order via the
visibleRowsderivation. - Cell components - the custom-cell pattern used for the expand chevron.