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

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>&lt;SvGrid&gt;</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>

View this example on GitHub

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).