Org chart tree
5-level employee hierarchy with role pills, department, headcount roll-up. Expand/collapse, expand-all/collapse-all.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Tree & Hierarchy). See the SvGrid documentation for the full API.
About this example
A five-level employee hierarchy in the Svelte 5 data grid, from CEO to VPs, directors, managers and individual contributors. The first column is a cell snippet that indents by depth, draws connector lines between parents and children and rotates a chevron on expand; the other columns are regular grid columns with a role pill, department, a headcount roll-up summed from descendants and tenure. The pattern is a flat array plus an expanded map, with the visible rows derived.
Five-level employee hierarchy (CEO -> VPs -> Directors -> Managers -> ICs) rendered as an expand/collapse tree. The first column is a custom cell template that indents by depth, draws connector lines between parents and children, and rotates an SVG chevron on expand. The other columns are regular SvGrid columns. Role pill, department, headcount rollup (sum of descendants), and tenure round it out.
Pattern: keep a flat allPeople array plus an expanded map; the visible row list is derived. The grid never has to know about the hierarchy directly.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature
Columns: role (Role), department (Department), headcount (Reports), tenure (Tenure)
Frequently asked questions
How is the tree rendered?
The rows stay a flat array with a depth and a parent id. A $derived filters it to the rows whose ancestors are all expanded, and that list is passed as data; the name column's snippet draws the indent and the chevron from the depth.
How is headcount rolled up?
A pass over the flat array counts descendants per node once, and the headcount column reads the result. Expand all and collapse all just replace the expanded map.
Is there a built-in tree mode?
Yes. The treeData prop nests rows by parent id with an expander, indent and treegrid accessibility. This hand-built version is the option when you need custom connector lines and a rendering of your own.
Related documentation
Related articles
- Displaying Tree Data and Hierarchies in SvGrid - Render parent-child hierarchies - org charts, file trees, category taxonomies - in SvGrid with expandable rows, size rollups, and lazy child loading.
- 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.
- Inside SvGrid: Grouping, Trees, and Master-Detail - Three different ways to show hierarchy in a data grid, unified under one expansion model in SvGrid - the design decision and how each feature actually works.
Source code (28-org-chart-tree.svelte)
<script lang="ts">
/**
* 28. Org chart - tree grid
* -------------------------
* Five-level employee hierarchy (CEO -> VPs -> Directors -> Managers -> ICs)
* rendered as an expand/collapse tree. The first column is a custom
* cell template that indents by depth, draws connector lines between
* parents and children, and rotates an SVG chevron on expand. The
* other columns are regular SvGrid columns. Role pill, department,
* headcount rollup (sum of descendants), and tenure round it out.
*
* Pattern: keep a flat `allPeople` array plus an `expanded` map; the
* visible row list is derived. The grid never has to know about the
* hierarchy directly.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
renderSnippet,
type GridColumns,
} from '@svgrid/grid'
type Role = 'CEO' | 'VP' | 'Director' | 'Manager' | 'IC'
type Person = {
id: string
parentId: string | null
depth: number
name: string
role: Role
department: string
tenure: number
headcount: number
childIds: string[]
}
function makeOrg(): Person[] {
type Seed = Omit<Person, 'headcount' | 'childIds'>
const seeds: Seed[] = [
{ id: 'p0', parentId: null, depth: 0, name: 'Grace Hopper', role: 'CEO', department: 'Executive', tenure: 12 },
{ id: 'p1', parentId: 'p0', depth: 1, name: 'Alan Turing', role: 'VP', department: 'Engineering', tenure: 9 },
{ id: 'p2', parentId: 'p0', depth: 1, name: 'Ada Lovelace', role: 'VP', department: 'Product', tenure: 8 },
{ id: 'p3', parentId: 'p0', depth: 1, name: 'Margaret Hamilton', role: 'VP', department: 'Operations', tenure: 11 },
{ id: 'p4', parentId: 'p0', depth: 1, name: 'Linus Torvalds', role: 'VP', department: 'Sales', tenure: 7 },
{ id: 'p5', parentId: 'p1', depth: 2, name: 'Donald Knuth', role: 'Director', department: 'Engineering', tenure: 6 },
{ id: 'p6', parentId: 'p1', depth: 2, name: 'Barbara Liskov', role: 'Director', department: 'Engineering', tenure: 5 },
{ id: 'p7', parentId: 'p5', depth: 3, name: 'Edsger Dijkstra', role: 'Manager', department: 'Engineering', tenure: 4 },
{ id: 'p8', parentId: 'p5', depth: 3, name: 'Niklaus Wirth', role: 'Manager', department: 'Engineering', tenure: 3 },
{ id: 'p9', parentId: 'p6', depth: 3, name: 'Anders Hejlsberg', role: 'Manager', department: 'Engineering', tenure: 5 },
{ id: 'p10', parentId: 'p7', depth: 4, name: 'Brendan Eich', role: 'IC', department: 'Engineering', tenure: 2 },
{ id: 'p11', parentId: 'p7', depth: 4, name: 'Yukihiro Matsumoto', role: 'IC', department: 'Engineering', tenure: 1 },
{ id: 'p12', parentId: 'p8', depth: 4, name: 'Bjarne Stroustrup', role: 'IC', department: 'Engineering', tenure: 3 },
{ id: 'p13', parentId: 'p8', depth: 4, name: 'Tim Berners-Lee', role: 'IC', department: 'Engineering', tenure: 4 },
{ id: 'p14', parentId: 'p9', depth: 4, name: 'Guido van Rossum', role: 'IC', department: 'Engineering', tenure: 5 },
{ id: 'p15', parentId: 'p9', depth: 4, name: 'Larry Wall', role: 'IC', department: 'Engineering', tenure: 6 },
{ id: 'p16', parentId: 'p9', depth: 4, name: 'James Gosling', role: 'IC', department: 'Engineering', tenure: 2 },
{ id: 'p17', parentId: 'p2', depth: 2, name: 'Mary Allen', role: 'Director', department: 'Product', tenure: 6 },
{ id: 'p18', parentId: 'p17', depth: 3, name: 'Karen Cox', role: 'Manager', department: 'Product', tenure: 4 },
{ id: 'p19', parentId: 'p18', depth: 4, name: 'Lisa Meyer', role: 'IC', department: 'Product', tenure: 2 },
{ id: 'p20', parentId: 'p18', depth: 4, name: 'Susan Goldberg', role: 'IC', department: 'Product', tenure: 3 },
{ id: 'p21', parentId: 'p17', depth: 3, name: 'Rich Hickey', role: 'Manager', department: 'Product', tenure: 5 },
{ id: 'p22', parentId: 'p21', depth: 4, name: 'Rich Sussman', role: 'IC', department: 'Product', tenure: 1 },
{ id: 'p23', parentId: 'p3', depth: 2, name: 'John McCarthy', role: 'Director', department: 'Operations', tenure: 7 },
{ id: 'p24', parentId: 'p23', depth: 3, name: 'Ken Thompson', role: 'Manager', department: 'Operations', tenure: 6 },
{ id: 'p25', parentId: 'p24', depth: 4, name: 'Brian Kernighan', role: 'IC', department: 'Operations', tenure: 5 },
{ id: 'p26', parentId: 'p24', depth: 4, name: 'Dennis Ritchie', role: 'IC', department: 'Operations', tenure: 4 },
{ id: 'p27', parentId: 'p23', depth: 3, name: 'Bram Moolenaar', role: 'Manager', department: 'Operations', tenure: 3 },
{ id: 'p28', parentId: 'p27', depth: 4, name: 'Joe Armstrong', role: 'IC', department: 'Operations', tenure: 2 },
{ id: 'p29', parentId: 'p4', depth: 2, name: 'Erik Cox', role: 'Director', department: 'Sales', tenure: 5 },
{ id: 'p30', parentId: 'p29', depth: 3, name: 'Anders Cox', role: 'Manager', department: 'Sales', tenure: 4 },
{ id: 'p31', parentId: 'p30', depth: 4, name: 'Linus Cox', role: 'IC', department: 'Sales', tenure: 2 },
{ id: 'p32', parentId: 'p30', depth: 4, name: 'Donald Cox', role: 'IC', department: 'Sales', tenure: 1 },
{ id: 'p33', parentId: 'p29', depth: 3, name: 'Rasmus Lerdorf', role: 'Manager', department: 'Sales', tenure: 3 },
{ id: 'p34', parentId: 'p33', depth: 4, name: 'Bram Lerdorf', role: 'IC', department: 'Sales', tenure: 1 },
]
const out: Person[] = seeds.map((s) => ({ ...s, headcount: 1, childIds: [] }))
const byId = new Map(out.map((n) => [n.id, n]))
for (const n of out) if (n.parentId) byId.get(n.parentId)!.childIds.push(n.id)
function rollup(id: string): number {
const node = byId.get(id)!
let count = 1
for (const cid of node.childIds) count += rollup(cid)
node.headcount = count
return count
}
rollup('p0')
return out
}
const features = tableFeatures({ rowSortingFeature })
const allPeople = makeOrg()
let expanded = $state<Record<string, boolean>>({ p0: true, p1: true, p2: true, p3: true, p4: true })
const visiblePeople = $derived.by(() => {
const out: Person[] = []
const byId = new Map(allPeople.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)
}
walk('p0')
return out
})
function toggle(id: string) {
expanded = { ...expanded, [id]: !expanded[id] }
}
function expandAll() {
const next: Record<string, boolean> = {}
for (const p of allPeople) if (p.childIds.length) next[p.id] = true
expanded = next
}
function collapseAll() {
expanded = { p0: true }
}
// KPI roll-ups
const kpis = $derived.by(() => {
const out: Record<Role, number> = { CEO: 0, VP: 0, Director: 0, Manager: 0, IC: 0 }
for (const p of allPeople) out[p.role] += 1
return out
})
// Keyboard navigation: Right Arrow expands, Left Arrow collapses,
// Enter / Space toggle, but only when the active cell is in the "name"
// column. Window-level capture listener so we run before SvGrid's
// own arrow-key cell mover and can preventDefault on the tree keys.
let activeCol = $state<string>('')
let activeRowIndex = $state<number>(0)
$effect(() => {
function onKey(e: KeyboardEvent) {
if (activeCol !== 'name') return
const node = visiblePeople[activeRowIndex]
if (!node || node.childIds.length === 0) return
const isOpen = !!expanded[node.id]
if (e.key === 'ArrowRight' && !isOpen) {
e.preventDefault(); toggle(node.id)
} else if (e.key === 'ArrowLeft' && isOpen) {
e.preventDefault(); toggle(node.id)
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); toggle(node.id)
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
})
// Role color + initials helpers
const ROLE_COLOR: Record<Role, string> = {
CEO: '#a855f7',
VP: '#3b82f6',
Director: '#06b6d4',
Manager: '#10b981',
IC: '#64748b',
}
function initials(name: string): string {
return name.split(' ').filter(Boolean).map((p) => p[0] ?? '').join('').slice(0, 2).toUpperCase()
}
const columns: GridColumns<Person> = [
{
id: 'name',
header: 'Member',
fieldFn: (row) => row.name,
cell: (ctx) => renderSnippet(NameCell, { node: ctx.row.original }),
width: 380,
},
{
field: 'role',
header: 'Role',
cell: (ctx) => renderSnippet(RoleCell, { role: ctx.row.original.role }),
width: 130,
},
{ field: 'department', header: 'Department', width: 150 },
{
field: 'headcount',
header: 'Reports',
width: 150,
cell: (ctx) => renderSnippet(HeadcountCell, { n: ctx.row.original.headcount }),
},
{
field: 'tenure',
header: 'Tenure',
width: 120,
cell: (ctx) => renderSnippet(TenureCell, { years: ctx.row.original.tenure }),
},
]
</script>
{#snippet NameCell(props: { node: Person })}
{@const canExpand = props.node.childIds.length > 0}
{@const isOpen = !!expanded[props.node.id]}
{@const color = ROLE_COLOR[props.node.role]}
<span class="t28-name" style={`padding-left: ${4 + props.node.depth * 22}px`}>
<!-- Tree connector lines: one vertical guide per ancestor level. -->
{#each Array(props.node.depth) as _, i (i)}
<span class="t28-guide" style={`left: ${4 + i * 22 + 11}px`}></span>
{/each}
{#if props.node.depth > 0}
<span class="t28-elbow" style={`left: ${4 + (props.node.depth - 1) * 22 + 11}px`}></span>
{/if}
{#if canExpand}
<button
type="button"
class={`t28-chev ${isOpen ? 't28-chev-open' : ''}`}
onclick={() => toggle(props.node.id)}
aria-label={isOpen ? 'Collapse' : 'Expand'}
aria-expanded={isOpen}
>
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="5 3 11 8 5 13" />
</svg>
</button>
{:else}
<span class="t28-dot" aria-hidden="true"></span>
{/if}
<span class="t28-avatar" style={`background: ${color}1f; color: ${color}; border: 1px solid ${color}40`}>
{initials(props.node.name)}
</span>
<span class="t28-name-text">
<span class="t28-name-title">{props.node.name}</span>
<span class="t28-name-sub">{props.node.department}</span>
</span>
</span>
{/snippet}
{#snippet RoleCell(props: { role: Role })}
{@const color = ROLE_COLOR[props.role]}
<span class="t28-role" style={`background: ${color}1a; color: ${color}; border: 1px solid ${color}40`}>
{props.role}
</span>
{/snippet}
{#snippet HeadcountCell(props: { n: number })}
<span class="t28-count">
<span class="t28-count-num tabular-nums">{props.n.toLocaleString('en-US')}</span>
<span class="t28-count-bar">
<span class="t28-count-bar-fill" style={`width: ${Math.min(100, (props.n / 35) * 100)}%`}></span>
</span>
</span>
{/snippet}
{#snippet TenureCell(props: { years: number })}
<span class="t28-tenure">
<span class="tabular-nums">{props.years}</span>
<span class="t28-tenure-sub">yr{props.years === 1 ? '' : 's'}</span>
</span>
{/snippet}
<section class="t28-shell flex flex-col flex-1 min-h-0 gap-3">
<!-- KPI strip -->
<div class="t28-kpi-strip">
{#each [
{ role: 'CEO', label: 'CEO' },
{ role: 'VP', label: 'VPs' },
{ role: 'Director', label: 'Directors' },
{ role: 'Manager', label: 'Managers' },
{ role: 'IC', label: 'ICs' },
] as r (r.role)}
{@const color = ROLE_COLOR[r.role as Role]}
<div class="t28-kpi" style={`--c: ${color}`}>
<div class="t28-kpi-bar"></div>
<div class="t28-kpi-label">{r.label}</div>
<div class="t28-kpi-value tabular-nums">{kpis[r.role as Role]}</div>
</div>
{/each}
<div class="t28-kpi t28-kpi-actions">
<button type="button" class="t28-btn" onclick={expandAll}>Expand all</button>
<button type="button" class="t28-btn t28-btn-ghost" onclick={collapseAll}>Collapse</button>
</div>
</div>
<div class="flex-1 min-h-0 t28-grid-wrap">
<SvGrid responsive={true}
columnResize
data={visiblePeople}
columns={columns}
features={features}
filterMode="none"
enableInlineEditing={false}
enableCellSelection={true}
rowHeight={52}
containerHeight="100%"
fitColumns={true}
onActiveCellChange={(args) => { activeCol = args.columnId; activeRowIndex = args.rowIndex }}
/>
</div>
<footer class="t28-foot">
{visiblePeople.length} visible of {allPeople.length} total · <strong>keyboard:</strong> Right expands, Left collapses, Enter/Space toggles (while focus is on a name cell)
</footer>
</section>
<style>
.t28-shell { min-height: 0; }
/* KPI strip */
.t28-kpi-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)) 220px;
gap: 10px;
flex-shrink: 0;
}
.t28-kpi {
position: relative;
border: 1px solid var(--sg-border, #e2e8f0);
background: var(--sg-bg, #ffffff);
border-radius: 10px;
padding: 12px 14px;
overflow: hidden;
}
.t28-kpi-bar {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--c);
}
.t28-kpi-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--sg-muted, #64748b);
margin-bottom: 4px;
}
.t28-kpi-value {
font-size: 22px;
font-weight: 700;
color: var(--c);
line-height: 1.1;
}
.t28-kpi-actions {
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
}
.t28-kpi-actions::before { content: none; }
.t28-btn {
border: 1px solid var(--sg-border, #cbd5e1);
background: var(--sg-bg, #ffffff);
color: var(--sg-fg, #1e293b);
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
}
.t28-btn:hover { background: var(--sg-header-bg, #f1f5f9); }
.t28-btn-ghost { background: transparent; border-color: transparent; color: var(--sg-muted, #64748b); }
/* Grid wrap */
.t28-grid-wrap {
border: 1px solid var(--sg-border, #e2e8f0);
border-radius: 10px;
background: var(--sg-bg, #ffffff);
overflow: hidden;
}
.t28-foot {
font-size: 11.5px;
color: var(--sg-muted, #64748b);
flex-shrink: 0;
}
/* Name cell - shared visual language across tree demos */
:global(.t28-name) {
display: inline-flex;
align-items: center;
gap: 10px;
position: relative;
width: 100%;
height: 100%;
}
:global(.t28-guide) {
position: absolute;
top: 0; bottom: 0;
width: 0;
border-left: 1px dashed var(--sg-border, rgba(148, 163, 184, 0.35));
pointer-events: none;
}
:global(.t28-elbow) {
position: absolute;
top: 50%;
width: 14px;
height: 0;
border-top: 1px dashed var(--sg-border, rgba(148, 163, 184, 0.45));
pointer-events: none;
}
:global(.t28-chev) {
border: 0;
background: transparent;
color: var(--sg-muted, #64748b);
width: 18px;
height: 18px;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 160ms ease, background 120ms ease, color 120ms ease;
flex-shrink: 0;
}
:global(.t28-chev:hover) {
background: var(--sg-header-bg, #f1f5f9);
color: var(--sg-fg, #1e293b);
}
:global(.t28-chev-open) { transform: rotate(90deg); }
:global(.t28-dot) {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
:global(.t28-dot::before) {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--sg-muted, rgba(148, 163, 184, 0.6));
}
:global(.t28-avatar) {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
letter-spacing: 0.02em;
}
:global(.t28-name-text) {
display: inline-flex;
flex-direction: column;
line-height: 1.2;
min-width: 0;
}
:global(.t28-name-title) { font-weight: 600; }
:global(.t28-name-sub) {
font-size: 11px;
color: var(--sg-muted, #64748b);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Role pill */
:global(.t28-role) {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* Headcount cell with mini-bar */
:global(.t28-count) {
display: inline-grid;
grid-template-columns: 42px 1fr;
gap: 8px;
align-items: center;
width: 100%;
}
:global(.t28-count-num) { font-weight: 700; }
:global(.t28-count-bar) {
height: 6px;
border-radius: 999px;
background: var(--sg-input-bg, #e2e8f0);
overflow: hidden;
}
:global(.t28-count-bar-fill) {
display: block;
height: 100%;
border-radius: 999px;
background: var(--sg-accent, #2563eb);
}
/* Tenure cell */
:global(.t28-tenure) {
display: inline-flex;
align-items: baseline;
gap: 4px;
}
:global(.t28-tenure > span:first-child) {
font-size: 16px;
font-weight: 700;
}
:global(.t28-tenure-sub) {
font-size: 11px;
color: var(--sg-muted, #64748b);
text-transform: uppercase;
letter-spacing: 0.04em;
}
</style>More Tree & Hierarchy examples
- Project WBS tree - Phase → task → subtask with % complete that rolls up via effort-weighted average when you edit any leaf.
- Bill of Materials - Bicycle BOM, 4 levels deep. Edit any leaf part's qty or unit cost; subtotals roll up through the assembly chain to the grand total.
- Lazy tree (load on expand) - Region → Country → State → City. Children fetched async on first expand with a "Loading…" placeholder row; subtrees cached on second expand.
- Tree + master/detail - Hierarchical file-system rows and an order/line-item master-detail view.
- Tree checkbox cascade - Permissions matrix as a tree: workspaces → resources → actions. Checking a parent checks descendants; partial state on parents. Live cost rollup.