Master / detail (nested grid)
The classic 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 from the SvGrid gallery (Master-Detail & Forms). See the SvGrid documentation for the full API.
About this example
Master and detail with a nested grid in Svelte 5. Expand any account row and a full-width detail row hosts another SvGrid listing that account's call records with their own sortable and filterable columns. It is built on the grid's isDetailRow and renderDetailRow props: the detail is a real colspan row, and its content happens to be a second grid.
The classic 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.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: name (Name), callId (Call ID), direction (Direction), number (Number), duration (Duration), switchCode (Switch)
Frequently asked questions
How do I put a grid inside a detail row?
Render <SvGrid> in the component passed as renderDetailRow, with data filtered to the master row's id. Each expanded account gets its own instance with independent sorting and filtering.
How is the expand state tracked?
An open set of account ids in $state. A $derived inserts a detail marker row after each open account, and isDetailRow tells the grid which rows to render through renderDetailRow.
Does the nested grid virtualize?
Yes, each nested grid is a normal grid with its own scroll container and containerHeight, so an account with thousands of calls stays smooth.
Related documentation
Related articles
- Master-Detail Rows - Expandable Detail Panels in SvGrid - How to wire expandable master-detail rows in SvGrid, from a nested grid of line items to lazy-fetched panels and programmatic expand on deep-link.
- Building an Order Management Dashboard in Svelte - How to wire up an order management grid with master-detail line items, status workflows, server-side data, and bulk fulfillment actions using SvGrid.
- Lazy-Loading Master-Detail Content in SvGrid - Fetch detail-panel data only when a row is expanded, cache the results, and cancel abandoned requests - keeping a large grid fast without paying for panels no one views.
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 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 GridColumns,
} 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 })
// The chevron is the grid's own row-header column (`showDetailToggle`);
// `onDetailToggle` and `isDetailOpen` below wire it to the expanded set.
const columns: GridColumns<AnyRow> = [
{ 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: GridColumns<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 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}
columnResize
data={props.row.callRecords}
columns={detailColumns}
features={detailFeatures}
selectionMode="none"
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}
columnResize
data={visible}
columns={columns}
features={features}
filterMode="menu"
selectionMode="none"
rowHeight={44}
containerHeight="100%"
fitColumns={true}
virtualization={false}
isDetailRow={(row) => row.kind === 'detail'}
renderDetailRow={DetailGrid}
showDetailToggle
onDetailToggle={(row) => toggle(row.id)}
isDetailOpen={(row) => expanded.has(row.id)}
/>
</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); }
/* 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>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).