Export grouped grid to Excel

A flat sales grid (Region → Country) exported via api.exportData({ format: "xlsx", groupBy }) which uses Smart's NATIVE Excel row outline grouping. Opens in Excel with +/- buttons in the row header gutter for every group level. (requires @svgrid/enterprise)

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

About this example

Exporting a grouped Svelte 5 data grid to Excel with native outline groups. A flat sales grid grouped by Region and optionally Country and Rep is exported with api.exportData({ format: 'xlsx', groupBy: ['region', 'country'] }); the exporter wraps every cluster of rows in an Excel outline row, so the file opens with the plus and minus buttons in the row gutter and Data, Outline behaviour, with no extra rows materialised by hand. A toolbar picks the grouping levels and a preview panel mirrors the workbook.

A flat sales grid grouped by Region (and optionally Country), exported with Smart's native row-outline grouping:

await api.exportData({ format: 'xlsx', filename: 'deals', groupBy: ['region', 'country'], // ← outline rows in xlsx })

The exporter wraps every cluster of rows in an Excel outline row with the +/- expand button - the file opens in Excel with native Data → Outline behavior, no extra rows materialized by hand.

The toolbar lets the user pick grouping levels (None / Region / Region → Country / Region → Country → Rep), and the preview panel mirrors what the exported workbook will contain.

Imports, features and API used

Imports: @svgrid/grid, @svgrid/enterprise

Table features registered: rowSortingFeature, columnFilteringFeature, columnGroupingFeature, rowExpandingFeature

Columns: region (Region), country (Country), rep (Sales rep), product (Product), stage (Stage), closeDate (Close date), amount (Amount), region (Region), country (Country), rep (Sales rep), product (Product), stage (Stage)

SvGridApi methods called: api.exportData(), api.setGroupBy()

Frequently asked questions

How do I export the grouping?

Pass groupBy with the ordered column ids to exportData. The exporter sorts the rows into clusters and writes an outline level per grouping depth, which Excel shows as collapsible groups.

Does the export follow the grid's current grouping?

The demo reads the grouping the user chose in the toolbar and passes it explicitly, which keeps the export predictable; you can also mirror api.setGroupBy so the two always match.

Are subtotals included?

Outline groups carry the rows; enableRowSummaries on the grid provides totals on screen, and the grouped PDF and print demo shows subtotal rows carried into the file.

Related documentation

Related articles

Source code (126-export-grouped-grid.svelte)

<script lang="ts">
  /**
   * 126. Export grouped grid to Excel (Pro)
   * ---------------------------------------
   * A flat sales grid grouped by Region (and optionally Country),
   * exported with Smart's native row-outline grouping:
   *
   *   await api.exportData({
   *     format: 'xlsx',
   *     filename: 'deals',
   *     groupBy: ['region', 'country'],   // ← outline rows in xlsx
   *   })
   *
   * The exporter wraps every cluster of rows in an Excel outline row
   * with the +/- expand button - the file opens in Excel with native
   * Data → Outline behavior, no extra rows materialized by hand.
   *
   * The toolbar lets the user pick grouping levels (None / Region /
   * Region → Country / Region → Country → Rep), and the preview panel
   * mirrors what the exported workbook will contain.
   */
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    columnGroupingFeature,
    rowExpandingFeature,
    type GridColumns,
    type SvGridApi,
  } from '@svgrid/grid'
  import {
    installEnterprise,
    setLicenseKey,
    type EnterpriseGridApi,
  } from '@svgrid/enterprise'

  setLicenseKey('SVENTERPRISE-DEV-DEMO')

  // ---- Domain --------------------------------------------------------
  type Region = 'AMER' | 'EMEA' | 'APAC'
  type Deal = {
    id: string
    region: Region
    country: string
    rep: string
    product: 'Cloud' | 'Pipeline' | 'AI' | 'Security'
    amount: number
    closeDate: string
    stage: 'Won' | 'Negotiation' | 'Proposal' | 'Discovery'
  }
  const TOPO: Record<Region, string[]> = {
    AMER: ['USA', 'Canada', 'Mexico'],
    EMEA: ['Germany', 'UK', 'France'],
    APAC: ['Japan', 'India', 'Australia'],
  }
  const REPS = [
    'Ada Lovelace', 'Linus Torvalds', 'Grace Hopper', 'Donald Knuth',
    'Tim Berners-Lee', 'Linda Petersen', 'Sven Andersson', 'Anders Hejlsberg',
    'Yuki Tanaka', 'Mei Chen', 'Raj Patel', 'Jin Park',
  ]
  const PRODUCTS = ['Cloud', 'Pipeline', 'AI', 'Security'] as const
  const STAGES = ['Won', 'Negotiation', 'Proposal', 'Discovery'] as const
  let prng = 0xE7E417ED >>> 0
  function rnd(): number { prng = (prng * 1664525 + 1013904223) >>> 0; return prng / 0xFFFFFFFF }
  function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }
  function pad(n: number): string { return String(n).padStart(2, '0') }

  function seed(): Deal[] {
    const out: Deal[] = []
    let id = 1
    for (const region of Object.keys(TOPO) as Region[]) {
      for (const country of TOPO[region]) {
        const n = 12 + Math.floor(rnd() * 24)
        for (let i = 0; i < n; i += 1) {
          const m = 1 + Math.floor(rnd() * 12)
          const d = 1 + Math.floor(rnd() * 27)
          out.push({
            id: `D-${pad(id++)}`,
            region, country,
            rep: pick(REPS),
            product: pick(PRODUCTS),
            amount: Math.round(2_500 + rnd() * 96_500),
            closeDate: `2026-${pad(m)}-${pad(d)}`,
            stage: pick(STAGES),
          })
        }
      }
    }
    return out
  }
  const deals: Deal[] = seed()

  // ---- Grid setup ----------------------------------------------------
  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
    columnGroupingFeature,
    rowExpandingFeature,
  })
  const columns: GridColumns<Deal> = [
    { field: 'region',    header: 'Region',    width: 90  },
    { field: 'country',   header: 'Country',   width: 110 },
    { field: 'rep',       header: 'Sales rep', width: 170 },
    { field: 'product',   header: 'Product',   width: 110 },
    { field: 'stage',     header: 'Stage',     width: 130 },
    { field: 'closeDate', header: 'Close date', width: 110, format: { type: 'date', pattern: 'y-m-d' } },
    { field: 'amount',    header: 'Amount',    width: 120, align: 'right',
      format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
  ]

  let api = $state<EnterpriseGridApi<typeof features, Deal> | null>(null)
  let groupBy = $state<string[]>(['region', 'country'])
  function applyGroup(by: string[]) {
    groupBy = by
    api?.setGroupBy(by)
  }
  function onApiReady(next: SvGridApi<typeof features, Deal>) {
    api = installEnterprise(next)
    api.setGroupBy(groupBy)
  }

  // ---- Export options -------------------------------------------------
  let zebra      = $state(true)
  let exporting  = $state(false)
  let lastExport = $state<string | null>(null)
  let exportError = $state<string | null>(null)

  /** Same column-field mapping the grid uses - the exporter projects
   *  each row to these fields in order. */
  const exportColumns = [
    { field: 'region',    header: 'Region'     },
    { field: 'country',   header: 'Country'    },
    { field: 'rep',       header: 'Sales rep'  },
    { field: 'product',   header: 'Product'    },
    { field: 'stage',     header: 'Stage'      },
    { field: 'closeDate', header: 'Close date' },
    { field: 'amount',    header: 'Amount'     },
  ]
  /** Sort deals by the groupBy fields so groups are contiguous - Smart
   *  expects each group's rows to come together in the input stream. */
  const sortedDeals = $derived.by(() => {
    if (groupBy.length === 0) return deals
    return [...deals].sort((a, b) => {
      for (const k of groupBy) {
        const av = String((a as unknown as Record<string, unknown>)[k] ?? '')
        const bv = String((b as unknown as Record<string, unknown>)[k] ?? '')
        if (av < bv) return -1
        if (av > bv) return 1
      }
      return 0
    })
  })

  /** Group counts for the preview panel + status note. */
  const groupCounts = $derived.by(() => {
    if (groupBy.length === 0) return [] as Array<{ label: string; count: number }>
    const counts = new Map<string, number>()
    for (const d of sortedDeals) {
      const key = groupBy.map((k) => String((d as unknown as Record<string, unknown>)[k] ?? '')).join(' / ')
      counts.set(key, (counts.get(key) ?? 0) + 1)
    }
    return Array.from(counts.entries()).map(([label, count]) => ({ label, count }))
  })

  async function exportXlsx() {
    if (!api) return
    exporting = true
    exportError = null
    lastExport = null
    try {
      const styles = zebra
        ? {
            headerRow: {
              backgroundColor: '#e0e7ff',
              color: '#3730a3',
              fontWeight: 'bold' as const,
              textAlign: 'left' as const,
            },
            rowAlternate: { backgroundColor: '#f8fafc' },
          }
        : undefined
      await api.exportData({
        format: 'xlsx',
        filename: `deals-grouped-${groupBy.join('-') || 'flat'}`,
        columns: exportColumns,
        rows: sortedDeals,
        // Smart's NATIVE Excel outline rows. Open the file in Excel
        // and the +/- buttons appear in the row header gutter for
        // every group level. No manually-materialised subtotal rows.
        ...(groupBy.length > 0 ? { groupBy } : {}),
        ...(styles ? { styles } : {}),
      })
      lastExport =
        groupBy.length > 0
          ? `Exported ${sortedDeals.length} rows in ${groupCounts.length} groups (Excel outline rows enabled)`
          : `Exported ${sortedDeals.length} flat rows`
    } catch (e) {
      exportError = e instanceof Error ? e.message : String(e)
      console.error('[grouped export]', e)
    } finally {
      exporting = false
    }
  }

  function fmtMoney(n: number | string | unknown): string {
    if (typeof n !== 'number' || !Number.isFinite(n)) return ''
    return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
  }
</script>

<section class="ex-shell flex flex-col flex-1 min-h-0 gap-3">
  <header class="ex-head">
    <h2>Export grouped grid to Excel</h2>
    <p>
      Wired to Pro's new <code>groupBy</code> export option, which maps straight to
      Smart DataExporter's native row-outline grouping. The xlsx opens in
      Excel with the +/- outline buttons in the row header gutter at every
      group level - no manually-emitted subtotal rows, the exporter wraps
      each cluster of rows automatically.
    </p>
  </header>

  <div class="ex-toolbar">
    <div class="ex-group-by">
      <span class="ex-label">Group by:</span>
      <button class="ex-chip" class:on={groupBy.length === 0}                    onclick={() => applyGroup([])}>None</button>
      <button class="ex-chip" class:on={groupBy.join() === 'region'}             onclick={() => applyGroup(['region'])}>Region</button>
      <button class="ex-chip" class:on={groupBy.join() === 'region,country'}     onclick={() => applyGroup(['region', 'country'])}>Region → Country</button>
      <button class="ex-chip" class:on={groupBy.join() === 'region,country,rep'} onclick={() => applyGroup(['region', 'country', 'rep'])}>Region → Country → Rep</button>
    </div>
    <div class="ex-actions">
      <label class="ex-opt"><input type="checkbox" bind:checked={zebra} /> Header fill + zebra rows</label>
      <button
        type="button"
        class="ex-export"
        disabled={exporting || api === null}
        onclick={exportXlsx}
      >
        {exporting ? 'Exporting…' : '⬇ Export to xlsx'}
      </button>
      {#if lastExport}<span class="ex-msg ok">{lastExport}</span>{/if}
      {#if exportError}<span class="ex-msg err">{exportError}</span>{/if}
    </div>
  </div>

  <div class="ex-split flex flex-1 min-h-0 gap-3">
    <div class="ex-grid-wrap flex-1 min-w-0">
      <SvGrid responsive={true}
      columnResize
        data={deals}
        {columns}
        {features}
        showRowSelection={false}
        showGroupingControls={true}
        enableInlineEditing={false}
        enableCellSelection={true}
        enableRowSummaries={true}
        rowHeight={32}
        containerHeight="100%"
        fitColumns={true}
        {onApiReady}
      />
    </div>

    <aside class="ex-preview">
      <div class="ex-preview-head">
        <span class="ex-preview-eyebrow">Export shape</span>
        <span class="ex-preview-count">{sortedDeals.length} rows · {groupCounts.length || 0} groups</span>
      </div>
      <div class="ex-preview-body">
        {#if groupBy.length === 0}
          <p class="ex-preview-empty">
            Flat export - every deal becomes one xlsx row, no grouping.
          </p>
        {:else}
          <p class="ex-preview-empty">
            Each cluster below becomes one collapsible outline row in the
            xlsx. The header row gutter in Excel will show ▲/▼ buttons to
            fold groups at each level.
          </p>
          <ul class="ex-group-list">
            {#each groupCounts.slice(0, 30) as g (g.label)}
              <li class="ex-group-li">
                <span class="ex-group-name">{g.label}</span>
                <span class="ex-group-count">{g.count} rows</span>
              </li>
            {/each}
            {#if groupCounts.length > 30}
              <li class="ex-group-overflow">… +{groupCounts.length - 30} more groups</li>
            {/if}
          </ul>
        {/if}

        <div class="ex-snippet-block">
          <div class="ex-snippet-title">Call</div>
          <pre class="ex-snippet"><code>{`await api.exportData({
  format: 'xlsx',
  filename: 'deals-grouped',
  columns: [/* 7 fields */],
  rows: sortedDeals,${groupBy.length > 0 ? `\n  groupBy: ${JSON.stringify(groupBy)},` : ''}${zebra ? '\n  styles: { headerRow, rowAlternate },' : ''}
})`}</code></pre>
        </div>
      </div>
    </aside>
  </div>
</section>

<style>
  .ex-shell { min-height: 0; }
  .ex-head h2 { font-size: 16px; font-weight: 700; margin: 0; }
  .ex-head p  { margin: 4px 0 0; font-size: 12.5px; color: var(--sg-muted, #64748b); max-width: 80ch; }
  .ex-head code {
    background: var(--sg-header-bg, #f1f5f9);
    padding: 1px 4px; border-radius: 3px;
    font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11.5px;
  }

  .ex-toolbar {
    display: flex; align-items: center; justify-content: space-between;
    gap: 12px; flex-wrap: wrap;
    padding: 10px 12px;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 8px;
    background: var(--sg-bg, #ffffff);
    flex-shrink: 0;
  }
  .ex-group-by { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
  .ex-label { font-size: 12px; font-weight: 600; color: var(--sg-fg, #1e293b); }
  .ex-chip {
    border: 1px solid var(--sg-border, #cbd5e1);
    background: var(--sg-bg, #ffffff);
    padding: 4px 10px; border-radius: 999px;
    font-size: 11.5px; cursor: pointer;
    color: var(--sg-fg, #1e293b);
  }
  .ex-chip.on {
    background: var(--sg-accent, #2563eb);
    border-color: var(--sg-accent, #2563eb);
    color: var(--sg-on-accent, #fff);
  }
  .ex-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
  .ex-opt { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; cursor: pointer; }
  .ex-export {
    border: 1px solid #16a34a;
    background: #16a34a;
    color: #fff;
    padding: 6px 14px; border-radius: 6px;
    font-size: 12.5px; font-weight: 700; cursor: pointer;
  }
  .ex-export:hover:not(:disabled) { filter: brightness(1.05); }
  .ex-export:disabled { opacity: 0.5; cursor: default; }
  .ex-msg { font-size: 11.5px; }
  .ex-msg.ok  { color: #15803d; font-weight: 600; }
  .ex-msg.err { color: #b91c1c; font-weight: 600; }
  :global([data-theme='dark']) .ex-msg.ok  { color: #4ade80; }
  :global([data-theme='dark']) .ex-msg.err { color: #f87171; }

  .ex-split { min-height: 0; }
  .ex-grid-wrap {
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 10px;
    background: var(--sg-bg, #ffffff);
    overflow: hidden;
  }

  .ex-preview {
    width: 380px; flex-shrink: 0;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 10px;
    background: var(--sg-bg, #ffffff);
    display: flex; flex-direction: column;
    overflow: hidden;
  }
  .ex-preview-head {
    display: flex; align-items: center; justify-content: space-between;
    padding: 8px 12px;
    border-bottom: 1px solid var(--sg-border, #e2e8f0);
    background: var(--sg-header-bg, #f8fafc);
  }
  .ex-preview-eyebrow {
    font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em;
    color: var(--sg-muted, #64748b); font-weight: 700;
  }
  .ex-preview-count { font-size: 11px; color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }
  .ex-preview-body { flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; }
  .ex-preview-empty { font-size: 12px; color: var(--sg-muted, #64748b); margin: 0; line-height: 1.5; }
  .ex-group-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px; }
  .ex-group-li {
    display: grid; grid-template-columns: 1fr auto; gap: 8px;
    align-items: center;
    padding: 4px 8px;
    border-radius: 5px;
    background: color-mix(in srgb, var(--sg-accent, #6366f1) 6%, transparent 94%);
    font-size: 11.5px;
  }
  :global([data-theme='dark']) .ex-group-li { background: color-mix(in srgb, var(--sg-accent, #6366f1) 14%, transparent 86%); }
  .ex-group-name { font-weight: 600; }
  .ex-group-count { font-size: 10.5px; color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }
  .ex-group-overflow {
    text-align: center; font-style: italic; font-size: 11px;
    color: var(--sg-muted, #64748b); padding: 4px 0;
    list-style: none;
  }

  .ex-snippet-block {
    border-top: 1px dashed var(--sg-border, #e2e8f0);
    padding-top: 10px;
  }
  .ex-snippet-title {
    font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em;
    color: var(--sg-muted, #64748b); font-weight: 700; margin-bottom: 6px;
  }
  .ex-snippet {
    margin: 0; padding: 8px 10px;
    /* Code block reads as an inset surface - fallbacks keep the old dark slab. */
    background: var(--sg-bg-subtle, var(--sg-header-bg, #0f172a)); color: var(--sg-fg, #e2e8f0);
    border-radius: 6px;
    font-family: ui-monospace, SFMono-Regular, monospace;
    font-size: 11px;
    line-height: 1.5;
    overflow: auto;
  }
</style>

View this example on GitHub

More Data Export & Import examples

  • Excel / CSV import - File picker + column mapping + per-row validation preview before commit. Reads xlsx / csv / tsv / json with format auto-detect.
  • Export + Print - Enterprise feature pack: download to Excel, PDF, CSV, TSV, HTML, or open a printable view in a new window.
  • Export - Theme-matched - One xlsx, light or dark - styles read from the same --sg-* tokens the grid renders with.
  • Export - Header + Footer + Logo - Branded xlsx: PNG logo + title + subtitle in the page header, generated date + page numbers in the footer.
  • Export - Cell images - Product grid with thumbnail column. On xlsx export each thumbnail is embedded as a real picture cell.