Epics -> stories (hierarchical cards)

Point board.childrenField at a row child rows and each card gains a children count that expands to show its sub-cards inline (Jira epic -> stories). Each child shows its own title + status. Board / Table switcher. (requires @svgrid/enterprise)

A live, editable Svelte 5 data grid example from the SvGrid gallery (Kanban). See the SvGrid documentation for the full API.

About this example

Hierarchical cards on the Svelte 5 data grid board: point board.childrenField at the property holding a row's child rows and each card gains a children count that expands to show its sub-cards inline, the epic to stories shape. Each child shows its own title and status, the parent still moves as one card, and a Board and Table switcher shows the same grid.

Point board.childrenField at a row's child rows and each card gains a children count that expands to show its sub-cards inline - the Jira epic -> stories shape. Each child shows its own title and status.

Imports, features and API used

Imports: @svgrid/enterprise, @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: title (Epic), owner (Owner), status (Status)

Frequently asked questions

What shape do the rows need?

Each parent row holds an array of child rows under the field named by childrenField; children use the same title and status fields, and the board reads them for the inline list.

Do children move with the parent?

Yes. Children are part of the parent row, so dragging the epic carries its stories; a story's own status is shown on the sub-card but does not place it in a lane.

Can I show a progress across children?

The card snippet can count children by status and draw a bar, the way the sub-task checklist does, using the same childrenField data.

Related documentation

Related articles

Source code (352-kanban-epics.svelte)

<script lang="ts">
  // Kanban board rendering is an Enterprise feature - register the renderer.
  import { enableBoardView } from "@svgrid/enterprise";
  enableBoardView();
  /**
   * 352. Epics -> stories (hierarchical cards)
   * ------------------------------------------
   * Point `board.childrenField` at a row's child rows and each card gains a
   * children count that expands to show its sub-cards inline - the Jira
   * epic -> stories shape. Each child shows its own title and status.
   */
  import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature, type ColumnDef, type BoardCardMoveEvent } from '@svgrid/grid'

  type Story = { id: number; title: string; status: string }
  type Epic = { id: number; title: string; status: string; owner: string; stories: Story[] }

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
  let view = $state<'board' | 'table'>('board')

  let rows = $state<Epic[]>([
    { id: 1, title: 'Kanban board mode', status: 'in_progress', owner: 'Sam Rivera', stories: [
      { id: 11, title: 'Drag & drop', status: 'done' },
      { id: 12, title: 'Keyboard move', status: 'done' },
      { id: 13, title: 'Virtualization', status: 'in_progress' },
    ] },
    { id: 2, title: 'Rich cards', status: 'in_progress', owner: 'Lee Park', stories: [
      { id: 21, title: 'Badges & tags', status: 'done' },
      { id: 22, title: 'Sub-tasks', status: 'done' },
      { id: 23, title: 'Comments', status: 'review' },
    ] },
    { id: 3, title: 'Server data source', status: 'todo', owner: 'Ada Cole', stories: [
      { id: 31, title: 'Grouping', status: 'todo' },
      { id: 32, title: 'Tree load-on-demand', status: 'todo' },
    ] },
    { id: 4, title: 'Studio 2.0', status: 'todo', owner: 'Sam Rivera', stories: [] },
    { id: 5, title: 'Docs overhaul', status: 'done', owner: 'Ada Cole', stories: [
      { id: 51, title: 'Kanban page', status: 'done' },
    ] },
  ])

  const columns: ColumnDef<any, Epic>[] = [
    { field: 'title', header: 'Epic', editorType: 'text' },
    { field: 'owner', header: 'Owner', editorType: 'text' },
    { field: 'status', header: 'Status' },
  ]

  const lanes = [
    { id: 'todo', title: 'To do' },
    { id: 'in_progress', title: 'In progress', color: '#f59e0b' },
    { id: 'review', title: 'Review', color: '#8b5cf6' },
    { id: 'done', title: 'Done', color: '#22c55e' },
  ]

  function onCardMove(e: BoardCardMoveEvent<Epic>) {
    e.row.status = e.toLane
  }

  const doneStories = (e: Epic) => e.stories.filter((s) => s.status === 'done').length
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="flex items-center justify-between gap-3 shrink-0">
    <div class="kb-note text-sm">
      The same <strong>&lt;SvGrid&gt;</strong>, two views. Each epic card shows its child
      <strong>stories</strong> - expand a card to see them inline, with their own status.
    </div>
    <div class="kb-seg inline-flex rounded-md border overflow-hidden text-sm shrink-0">
      <button class="kb-seg-btn px-3 py-1 {view === 'board' ? 'kb-on' : ''}"
        onclick={() => (view = 'board')}>Board</button>
      <button class="kb-seg-btn px-3 py-1 {view === 'table' ? 'kb-on' : ''}"
        onclick={() => (view = 'table')}>Table</button>
    </div>
  </div>

  <div class="flex-1 min-h-0">
    {#if view === 'board'}
      <SvGrid responsive={true}
      columnResize
        data={rows}
        columns={columns}
        getRowId={(r) => String(r.id)}
        containerHeight="100%"
        board={{
          groupBy: 'status',
          lanes,
          childrenField: 'stories',
          editable: true,
          laneSummary: (list) => `${list.length}`,
          onCardMove,
        }}
      />
    {:else}
      <SvGrid responsive={true}
      columnResize
        data={rows}
        columns={columns}
        features={features}
        getRowId={(r) => String(r.id)}
        sortable
        filterable
        enableInlineEditing
        rowHeight={36}
        containerHeight="100%"
        fitColumns
      />
    {/if}
  </div>

  <footer class="kb-foot text-sm shrink-0">
    {rows.length} epics ยท {rows.reduce((s, e) => s + doneStories(e), 0)} of
    {rows.reduce((s, e) => s + e.stories.length, 0)} stories done
  </footer>
</section>

<style>
  /* Caption + view-switcher chrome follows the active grid theme. */
  .kb-note { color: var(--sg-fg, #475569); }
  .kb-foot { color: var(--sg-muted, #64748b); }
  .kb-seg { border-color: var(--sg-border, #cbd5e1); }
  .kb-seg-btn {
    background: transparent;
    color: var(--sg-fg, #0f172a);
    cursor: pointer;
  }
  .kb-seg-btn.kb-on { background: var(--sg-accent, #1e293b); color: var(--sg-on-accent, #fff); }
</style>

View this example on GitHub

More Kanban examples

  • Kanban board mode - The same <SvGrid>, same data + columns, rendered as a Kanban board by setting one `board` prop: rows become cards in horizontal lanes bucketed by a `status` field. Built-in drag-and-drop AND keyboard move (Space + arrows) reassign the lane and reorder; double-click / F2 opens a built-in card editor; WIP limits, add-card, and a rich card snippet (assignee avatar + priority chip). Toggle Board / Table to see it is one grid.
  • Sprint board (swimlanes + WIP) - An agile sprint board: status lanes crossed with a swimlane per assignee, an enforced WIP limit on In progress, and a story-point roll-up in every lane header via board.laneSummary. Drag across a band to reassign the owner; double-click to edit. Collapsible lanes.
  • Sales pipeline (deal board) - A CRM deal board: stages as lanes with a live total-value roll-up per stage (board.laneSummary). Rich deal cards; drag to advance a deal, double-click to edit. Moves and edits persist to localStorage via onCardMove / onCardCommit, so a reload restores the board.
  • Large board (virtualized) - Thousands of cards per lane kept smooth with board.virtualized - only the cards in view are in the DOM. A slider scales the board from 1k to 50k cards; drag-and-drop and keyboard move still work across the whole window.
  • Support triage board - A helpdesk triage board: status lanes crossed with a priority swimlane, a search box front-and-center, and an SLA colour bar on each card driven by ticket age. Drag across a band to re-triage a ticket priority.