Columns hierarchy & manager

The pattern for grids with too many columns to fit on screen at once. A side-panel "column manager" exposes the column tree to the user: they pick what's visible, what gets collapsed into a one-cell summary, and what order leaves appear in within each group.

A multi-level header where parent groups Q1 and Q2 span their child month columns, with a Q3 group collapsed to one summary column.

Try it live - drag a leaf chip between siblings, click a group's chevron to collapse it into a summary column, toggle a checkbox to hide:

Open the live example: Columns hierarchy + manager (Columns)

What it builds on

Two existing engine capabilities power this:

  1. Nested column groups. A ColumnDef with columns: [...] produces multi-row headers automatically; see Column groups.
  2. Dynamic columns. The columns prop is a regular array; passing a $derived array makes it reactive. The grid swaps the column tree in place on every change.

The "hierarchy manager" is just a Svelte component you write that mutates a piece of $state describing which groups are open + which leaves are visible. The derived columns array reflects that state.

Data model

The state has one entry per group:

type GroupTreeState = {
  id: string
  visible: boolean                       // whole-group hide
  collapsed: boolean                     // collapsed to one summary column
  leafOrder: string[]                    // leaf ids, in display order
  leafVisible: Record<string, boolean>   // per-leaf hide
}

let treeState = $state<GroupTreeState[]>(initialTree())

Initial state walks your fixed group definitions and turns every leaf into visible: true. From then on, every interaction in the side panel produces a new array (no mutation in place - Svelte 5 prefers reassignment).

Deriving the column tree

The grid's columns are a $derived from the tree state plus your fixed column specs:

const columnTree = $derived.by((): ColumnDef<F, Row>[] => {
  const out: ColumnDef<F, Row>[] = []
  for (const g of treeState) {
    if (!g.visible) continue
    const spec = GROUPS.find((s) => s.id === g.id)!
    if (g.collapsed) {
      // One synthetic column showing the group's summary.
      out.push({
        id: `summary_${g.id}`,
        header: `${spec.label} · summary`,
        width: 200,
        cell: (ctx) => renderSnippet(SummaryCell, {
          row: ctx.row.original,
          groupId: g.id,
        }),
      })
      continue
    }
    // Otherwise: emit the group as a real nested column group.
    const visibleLeaves = g.leafOrder
      .map((id) => spec.leaves.find((l) => l.id === id)!)
      .filter((l) => g.leafVisible[l.id])
    if (visibleLeaves.length === 0) continue
    out.push({
      id: `group_${g.id}`,
      header: spec.label,
      columns: visibleLeaves.map(buildLeaf),
    })
  }
  return out
})

buildLeaf is a small helper that maps your fixed leaf spec ({ id, field, label, width }) into a ColumnDef. For columns whose display is a custom snippet, dispatch by id and emit the corresponding cell: renderSnippet(...).

Group "summary" cells

When the user collapses a group, you don't usually want it to vanish - the user wants to see one representative value for it. The Engagement group in the demo collapses to a health pill; Deal value collapses to the ARR; Account collapses to the company name plus an industry / region sub-line.

The summary cell snippet takes a groupId prop and switches on it:

{#snippet SummaryCell(props: { row: Deal; groupId: string })}
  {#if props.groupId === 'value'}
    <span class="flex flex-col">
      <span class="font-bold tabular-nums">{fmtMoney(props.row.arr)}</span>
      <span class="text-xs text-muted tabular-nums">{props.row.probability}% prob</span>
    </span>
  {:else if props.groupId === 'engagement'}
    <HealthPill health={props.row.health} />
  {/if}
{/snippet}

Side-panel UI

Three things the user controls per group:

Control What it does
Chevron () Toggle collapsed. Group becomes 1 column, or expands back.
Group checkbox Toggle visible. Hides the whole group.
Leaf checkbox Toggle leafVisible[id]. Hides one column.

Drag-and-drop within a group reorders leaves:

function onDrop(e: DragEvent, groupId: string, targetLeafId: string) {
  e.preventDefault()
  if (!drag || drag.groupId !== groupId) return
  const group = treeState.find((g) => g.id === groupId)!
  const fromIx = group.leafOrder.indexOf(drag.leafId)
  const toIx = group.leafOrder.indexOf(targetLeafId)
  if (fromIx < 0 || toIx < 0 || fromIx === toIx) return
  const next = group.leafOrder.slice()
  next.splice(fromIx, 1)
  next.splice(toIx, 0, drag.leafId)
  treeState = treeState.map((g) =>
    g.id === groupId ? { ...g, leafOrder: next } : g,
  )
}

The demo restricts drag to same-group reorder because cross-group drag would change the semantic grouping (a "Deal value · Stage" mix doesn't make sense). If your data model allows cross-group moves, drop the drag.groupId === groupId guard.

Persistence

The tree state is a plain JS object - feed it into your Saved views pipeline and the user's column layout follows them across sessions:

// Save
saveView({ ...currentView, columnTree: treeState })
// Restore
treeState = view.columnTree ?? initialTree()

For multi-user / shared layouts, store under a per-team key alongside your other config.

Performance

Re-deriving columnTree runs at 0.1 ms even for 40-leaf trees on a modern machine; the cost is dominated by the grid's column-rebuild work afterward (1-2 ms). Mutating the tree state at interactive rates (drag-resize over time) is fine.

Composing with the rest of the grid

Nested header groups

A group is a column with columns instead of a field, and it nests as deep as you need. The header row count follows the deepest branch, so a two-level group next to a plain column lines up without spacers.

<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
    joined: string
  }

  const people: Person[] = [
    { id: 1, name: 'Ada Lovelace',   department: 'Engineering', city: 'London',   age: 36, salary: 142000, joined: '2021-03-01' },
    { id: 2, name: 'Grace Hopper',   department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15' },
    { id: 3, name: 'Linus Torvalds', department: 'Platform',    city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20' },
    { id: 4, name: 'Radia Perlman',  department: 'Networking',  city: 'Seattle',  age: 49, salary: 161000, joined: '2022-09-05' },
    { id: 5, name: 'Barbara Liskov', department: 'Platform',    city: 'Boston',   age: 52, salary: 172000, joined: '2018-11-11' },
  ]

  const columns: GridColumns<Person> = [
    { field: 'name', header: 'Name', width: 180 },
    {
      header: 'Employment',
      columns: [
        { field: 'department', header: 'Department', width: 150 },
        {
          header: 'Compensation',
          columns: [
            { field: 'salary', header: 'Salary', width: 130,
              format: { type: 'currency', currency: 'USD' } },
            { field: 'joined', header: 'Since', width: 120 },
          ],
        },
      ],
    },
    { field: 'city', header: 'City', width: 130 },
  ]
</script>

<SvGrid data={people} {columns} sortable />

Groups and pinning together

Pinning names a leaf column, not a group. Pin a leaf and its group header travels with it - which is why the pinned region can show a header spanning one column.

<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
    joined: string
  }

  const people: Person[] = [
    { id: 1, name: 'Ada Lovelace',   department: 'Engineering', city: 'London',   age: 36, salary: 142000, joined: '2021-03-01' },
    { id: 2, name: 'Grace Hopper',   department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15' },
    { id: 3, name: 'Linus Torvalds', department: 'Platform',    city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20' },
    { id: 4, name: 'Radia Perlman',  department: 'Networking',  city: 'Seattle',  age: 49, salary: 161000, joined: '2022-09-05' },
    { id: 5, name: 'Barbara Liskov', department: 'Platform',    city: 'Boston',   age: 52, salary: 172000, joined: '2018-11-11' },
  ]

  const columns: GridColumns<Person> = [
    {
      header: 'Identity',
      columns: [
        { field: 'name', header: 'Name', width: 170 },
        { field: 'city', header: 'City', width: 130 },
      ],
    },
    {
      header: 'Employment',
      columns: [
        { field: 'department', header: 'Department', width: 160 },
        { field: 'salary', header: 'Salary', width: 140,
          format: { type: 'currency', currency: 'USD' } },
      ],
    },
  ]
</script>

<SvGrid data={people} {columns} initialColumnPinning={{ left: ['name'] }} />

See also

Related articles