Master / detail (nested grid)
The classic AG-Grid master/detail: expand any account row to reveal a full nested SvGrid of its call records. Built on isDetailRow + renderDetailRow - a real full-width detail row hosting another grid.
A live, editable Svelte 5 data grid example. Open the interactive demo or read the documentation.
What this example shows
The classic AG-Grid-style master/detail: each master row expands to reveal a full nested <SvGrid responsive={true}> of its child records. Built on the grid's own `isDetailRow` + `renderDetailRow` props - the detail row is a real full-width colspan row, and its content is just another grid.
Master = accounts; Detail = that account's call records.
Source code (181-master-detail-grid.svelte)
<!-- Documented in: docs/help/rows/master-detail.md -->
<script lang="ts">
/**
* 181. Master / Detail - nested grids
* -----------------------------------
* The classic AG-Grid-style master/detail: each master row expands to reveal
* a full nested <SvGrid responsive={true}> of its child records. Built on the grid's own
* `isDetailRow` + `renderDetailRow` props - the detail row is a real
* full-width colspan row, and its content is just another grid.
*
* Master = accounts; Detail = that account's call records.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
renderSnippet,
type ColumnDef,
} from '@svgrid/grid'
type Call = {
callId: number
direction: 'Inbound' | 'Outbound'
number: string
duration: number // seconds
switchCode: string
}
type Account = {
kind: 'account'
id: string
name: string
account: number
country: string
calls: number
minutes: number
callRecords: Call[]
}
type Detail = { kind: 'detail'; id: string; parentId: string; name: string; callRecords: Call[] }
type AnyRow = Account | Detail
const SWITCHES = ['SW5', 'SW3', 'SW7', 'SW2', 'SW9']
const NUMBERS = ['(02) 8988 1234', '(03) 9011 5567', '(07) 3210 9988', '(08) 6122 4531', '(02) 4455 7781']
function makeCalls(seed: number, n: number): Call[] {
const out: Call[] = []
for (let i = 0; i < n; i += 1) {
const k = seed * 13 + i * 7
out.push({
callId: 1000 + seed * 100 + i,
direction: k % 2 === 0 ? 'Inbound' : 'Outbound',
number: NUMBERS[k % NUMBERS.length]!,
duration: 30 + (k * 17) % 540,
switchCode: SWITCHES[k % SWITCHES.length]!,
})
}
return out
}
const accounts: Account[] = [
{ kind: 'account', id: 'A-1001', name: 'Ada Lovelace', account: 177001, country: 'United Kingdom', calls: 4, minutes: 32, callRecords: makeCalls(1, 4) },
{ kind: 'account', id: 'A-1002', name: 'Grace Hopper', account: 177002, country: 'United States', calls: 6, minutes: 51, callRecords: makeCalls(2, 6) },
{ kind: 'account', id: 'A-1003', name: 'Linus Torvalds', account: 177003, country: 'Finland', calls: 3, minutes: 18, callRecords: makeCalls(3, 3) },
{ kind: 'account', id: 'A-1004', name: 'Margaret Hamilton', account: 177004, country: 'United States', calls: 7, minutes: 63, callRecords: makeCalls(4, 7) },
{ kind: 'account', id: 'A-1005', name: 'Tim Berners-Lee', account: 177005, country: 'United Kingdom', calls: 5, minutes: 40, callRecords: makeCalls(5, 5) },
]
let expanded = $state<Set<string>>(new Set(['A-1002']))
function toggle(id: string) {
const next = new Set(expanded)
if (next.has(id)) next.delete(id); else next.add(id)
expanded = next
}
function expandAll() { expanded = new Set(accounts.map((a) => a.id)) }
function collapseAll() { expanded = new Set() }
// Splice a detail sentinel after each expanded account.
const visible = $derived.by<AnyRow[]>(() => {
const out: AnyRow[] = []
for (const a of accounts) {
out.push(a)
if (expanded.has(a.id)) {
out.push({ kind: 'detail', id: `${a.id}-d`, parentId: a.id, name: a.name, callRecords: a.callRecords })
}
}
return out
})
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
const columns: ColumnDef<typeof features, AnyRow>[] = [
{
id: 'expand', header: '', width: 40, editable: false, sortable: false, filterable: false,
cell: (ctx) => ctx.row.original.kind === 'account'
? renderSnippet(Chevron, { id: ctx.row.original.id, open: expanded.has(ctx.row.original.id) })
: renderSnippet(Blank, {}),
},
{ field: 'name', header: 'Name', width: 190, editable: false,
cell: (ctx) => ctx.row.original.kind === 'account'
? renderSnippet(Plain, { value: ctx.row.original.name }) : renderSnippet(Blank, {}) },
{ id: 'account', header: 'Account', width: 120, align: 'right', editable: false,
cell: (ctx) => ctx.row.original.kind === 'account'
? renderSnippet(Plain, { value: String(ctx.row.original.account) }) : renderSnippet(Blank, {}) },
{ id: 'country', header: 'Country', width: 170, editable: false,
cell: (ctx) => ctx.row.original.kind === 'account'
? renderSnippet(Plain, { value: ctx.row.original.country }) : renderSnippet(Blank, {}) },
{ id: 'calls', header: 'Calls', width: 100, align: 'right', editable: false,
cell: (ctx) => ctx.row.original.kind === 'account'
? renderSnippet(Plain, { value: String(ctx.row.original.calls) }) : renderSnippet(Blank, {}) },
{ id: 'minutes', header: 'Minutes', width: 110, align: 'right', editable: false,
cell: (ctx) => ctx.row.original.kind === 'account'
? renderSnippet(Plain, { value: `${ctx.row.original.minutes} min` }) : renderSnippet(Blank, {}) },
]
// Nested detail grid columns (the child grid).
const detailFeatures = tableFeatures({ rowSortingFeature })
const detailColumns: ColumnDef<typeof detailFeatures, Call>[] = [
{ field: 'callId', header: 'Call ID', width: 100, cellDataType: 'number' },
{ field: 'direction', header: 'Direction', width: 120 },
{ field: 'number', header: 'Number', width: 160 },
{ field: 'duration', header: 'Duration', width: 110, align: 'right',
formatter: ({ value }) => {
const s = Number(value)
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
} },
{ field: 'switchCode', header: 'Switch', width: 90 },
]
</script>
{#snippet Chevron(props: { id: string; open: boolean })}
<button class="md-chev" aria-label={props.open ? 'Collapse' : 'Expand'} onclick={() => toggle(props.id)}>
<svg class={props.open ? 'is-open' : ''} viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 4 10 8 6 12"></polyline>
</svg>
</button>
{/snippet}
{#snippet Blank()}{/snippet}
{#snippet Plain(props: { value: string })}<span>{props.value}</span>{/snippet}
{#snippet DetailGrid(props: { row: AnyRow; rowIndex: number })}
{#if props.row.kind === 'detail'}
<div class="md-detail">
<div class="md-detail-title">
Call records for <strong>{props.row.name}</strong>
<span class="md-detail-count">{props.row.callRecords.length} calls</span>
</div>
<div class="md-detail-grid">
<SvGrid responsive={true}
data={props.row.callRecords}
columns={detailColumns}
features={detailFeatures}
selectionMode="none"
showPagination={false}
enableRowSummaries={false}
rowHeight={34}
containerHeight="100%"
fitColumns={true}
virtualization={false}
/>
</div>
</div>
{/if}
{/snippet}
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="md-toolbar shrink-0">
<div class="md-info">
Click a <strong>chevron</strong> to expand an account into a nested grid of its
call records - a real full-width detail row hosting another <code><SvGrid></code>.
</div>
<div class="md-actions">
<button class="md-btn" onclick={expandAll}>Expand all</button>
<button class="md-btn alt" onclick={collapseAll}>Collapse all</button>
<span class="md-count">{expanded.size} of {accounts.length} open</span>
</div>
</div>
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
data={visible}
columns={columns}
features={features}
filterMode="menu"
selectionMode="none"
showPagination={false}
enableRowSummaries={false}
rowHeight={44}
containerHeight="100%"
fitColumns={true}
virtualization={false}
isDetailRow={(row) => row.kind === 'detail'}
renderDetailRow={DetailGrid}
/>
</div>
</section>
<style>
.md-toolbar {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
border: 1px solid var(--sg-border, #e2e8f0); background: var(--sg-bg, #fff);
border-radius: 8px; padding: 10px 14px;
}
.md-info { flex: 1; min-width: 280px; font-size: 13px; color: var(--sg-fg, #0f172a); }
.md-info strong { color: var(--sg-accent, #6366f1); }
.md-actions { display: flex; align-items: center; gap: 8px; }
.md-btn {
background: var(--sg-accent, #6366f1); color: var(--sg-on-accent, #fff); border: 0;
border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: 700; cursor: pointer;
}
.md-btn.alt { background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); border: 1px solid var(--sg-border, #cbd5e1); }
.md-btn:hover { filter: brightness(1.07); }
.md-count { font-size: 11px; color: var(--sg-muted, #64748b); }
:global(.md-chev) {
width: 24px; height: 24px; background: transparent; border: 0; cursor: pointer;
color: var(--sg-muted, #64748b); border-radius: 4px;
display: inline-flex; align-items: center; justify-content: center;
}
:global(.md-chev:hover) { background: color-mix(in oklab, var(--sg-accent, #6366f1) 12%, transparent); color: var(--sg-accent, #6366f1); }
:global(.md-chev svg) { transition: transform 140ms; }
:global(.md-chev svg.is-open) { transform: rotate(90deg); }
/* Detail row: a nested grid on an inset, accent-bordered panel. */
:global(.md-detail) {
border-left: 3px solid var(--sg-accent, #6366f1);
background: color-mix(in oklab, var(--sg-accent, #6366f1) 4%, var(--sg-bg, #fff));
padding: 12px 16px; width: 100%; box-sizing: border-box;
}
:global(.md-detail-title) {
font-size: 12px; color: var(--sg-muted, #64748b); margin-bottom: 8px;
display: flex; align-items: center; gap: 10px;
}
:global(.md-detail-title strong) { color: var(--sg-fg, #0f172a); }
:global(.md-detail-count) {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
background: color-mix(in oklab, var(--sg-accent, #6366f1) 14%, transparent); color: var(--sg-accent, #6366f1);
padding: 1px 7px; border-radius: 999px;
}
:global(.md-detail-grid) {
height: 200px;
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 6px; overflow: hidden;
background: var(--sg-bg, #fff);
}
</style>Related documentation
More Master-Detail & Forms examples
- Forms-in-grid (master/detail) - Master grid + tabbed detail form. Dirty tracking, inline validation, atomic Save / Discard, contact subgrid.
- Multi-grid sync - Two grids over one $state array - edits on the left propagate to the right instantly; each grid keeps its own filter + sort.
- Side-drawer edit form - Linear / Notion pattern: click a row, a polished drawer slides in with the full record, dirty-state badge, live validation, Esc / Ctrl+Enter shortcuts.
- Detail rows (expandable) - Stripe / GitHub-style inline row expansion: click the chevron to reveal a 4-panel detail (line items, shipping, payments, support thread).