Master / detail (nested grids)

Master/detail is the pattern where each master row expands to reveal a detail panel - most often a nested grid of the row's child records. SvGrid builds it from two props on <SvGrid>:

An expanded master row reveals a full-width detail region beneath it, where isDetailRow marks the sentinel row and renderDetailRow draws its nested content.

Open the live example: Master / detail (nested grid) (Master-Detail & Forms)

Master detail rows in SvGrid (34 s, silent).
Transcript

Master detail in SvGrid is two props. isDetailRow marks a row as a detail, and renderDetailRow draws whatever you like inside it.

Click the chevron on an account to expand its call records, a nested grid with its own sorting.

Expand as many as you like. Each detail is a real row in the grid, so keyboard navigation and virtualization keep working.

Click the chevron again to collapse. The expanded set is plain state, so you can save and restore it.

Pattern

Keep an expanded set, and splice a "detail" sentinel row into the data right after each expanded master row. isDetailRow recognises the sentinels; renderDetailRow renders the child grid.

The examples on this page run against these rows:

<script lang="ts">
  import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'

  type Person = {
    id: number
    name: string
    department: string
    city: string
    age: number
    salary: number
  }

  const people: Person[] = [
    { id: 1, name: 'Ada Lovelace',   department: 'Engineering', city: 'London',   age: 36, salary: 142000 },
    { id: 2, name: 'Grace Hopper',   department: 'Engineering', city: 'New York', age: 45, salary: 168000 },
    { id: 3, name: 'Linus Torvalds', department: 'Platform',    city: 'Portland', age: 54, salary: 155000 },
    { id: 4, name: 'Radia Perlman',  department: 'Networking',  city: 'Seattle',  age: 49, salary: 161000 },
    { id: 5, name: 'Barbara Liskov', department: 'Platform',    city: 'Boston',   age: 52, salary: 172000 },
  ]

  const columns: GridColumns<Person> = [
    { field: 'name',       header: 'Name',       width: 190 },
    { field: 'department', header: 'Department', width: 150 },
    { field: 'city',       header: 'City',       width: 130 },
    { field: 'age',        header: 'Age',        width: 80 },
    { field: 'salary',     header: 'Salary',     width: 130, format: { type: 'currency', currency: 'USD' } },
  ]
</script>
<script lang="ts">
  type Row = Account | { kind: 'detail'; parentId: string; children: Call[] }

  let expanded = $state(new Set<string>())
  const toggle = (id: string) => {
    const next = new Set(expanded)
    next.has(id) ? next.delete(id) : next.add(id)
    expanded = next
  }

  // Master rows + a detail sentinel after each expanded one.
  const visible = $derived.by(() => {
    const out: Row[] = []
    for (const a of accounts) {
      out.push(a)
      if (expanded.has(a.id)) out.push({ kind: 'detail', parentId: a.id, children: a.callRecords })
    }
    return out
  })
</script>

{#snippet DetailGrid(p: { row: Row })}
  {#if p.row.kind === 'detail'}
    <div style="height: 200px;">
      <SvGrid data={p.row.children} columns={detailColumns} containerHeight="100%" />
    </div>
  {/if}
{/snippet}

<SvGrid
  data={visible}
  {columns}
  isDetailRow={(row) => row.kind === 'detail'}
  renderDetailRow={DetailGrid}
  showDetailToggle
  onDetailToggle={(row) => toggle(row.id)}
  isDetailOpen={(row) => expanded.has(row.id)}
/>

The toggle column

showDetailToggle adds the chevrons as a row-header column, beside the row numbers and the selection checkbox: sticky at the left, on the header band's colour, with no column menu, no resize handle, and outside the active cell and the selection. A chevron calls onDetailToggle(row, rowIndex) and points down while isDetailOpen(row) is true; hasDetail leaves it out on rows with nothing to open, and a detail row gets an empty cell. Ctrl+Enter on a row does what the chevron does. Before the column existed, demos drew the chevron in a data column of their own, which took the menu, the resize handle and the active cell along with it.

Notes

More examples

Tree + master/detail

Hierarchical file-system rows and an order/line-item master-detail view.

Open the live example: Tree + master/detail (Tree & Hierarchy)

Try it

A detail row is an ordinary row that isDetailRow identifies and renderDetailRow draws. Because it is a row, virtualization and scrolling treat it like any other - which is also why a variable-height panel wants virtualization={false}.

<script lang="ts">
  type Row = { kind: 'master' | 'detail'; name?: string; city?: string; note?: string }

  const rows: Row[] = [
    { kind: 'master', name: 'Ada Lovelace', city: 'London' },
    { kind: 'detail', note: 'Joined 2021. Works on the compiler team.' },
    { kind: 'master', name: 'Grace Hopper', city: 'New York' },
    { kind: 'detail', note: 'Joined 2019. Owns the release process.' },
  ]

  const cols: GridColumns<Row> = [
    { field: 'name', header: 'Name', width: 200 },
    { field: 'city', header: 'City', width: 160 },
  ]
</script>

<SvGrid
  data={rows}
  columns={cols}
  virtualization={false}
  isDetailRow={(row) => row.kind === 'detail'}
>
  {#snippet renderDetailRow(p)}
    <div class="detail">{p.row.note}</div>
  {/snippet}
</SvGrid>

<style>
  .detail { padding: 10px 16px; font-size: 13px; opacity: 0.85; }
</style>

See also

Live examples

  • 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.
  • Tree + master/detail - Hierarchical file-system rows and an order/line-item master-detail view.
  • Detail rows (expandable) - Stripe / GitHub-style inline row expansion: click the chevron to reveal a 4-panel detail (line items, shipping, payments, support thread).

Related articles