Export to Excel Implementation

Overview

VanillaGrid and <vn-grid> expose an exportToExcel(options) method that writes an .xlsx workbook and hands it to the browser as a download (or, on iOS, to the system share sheet), entirely client-side, with no server round-trip and no third-party library.

The grid writes the workbook itself. Rows are read a chunk at a time and streamed into a writer that produces the worksheet XML, compresses it with the browser's native CompressionStream('deflate-raw') and assembles the ZIP package. Memory grows with the compressed file, not with the number of cells, and no main-thread task blocks the page for long, so exports scale to Excel's own sheet limit: on the reference machine 100,000 rows × 21 columns take about 3 s and 1,000,000 rows about 30 s (see Performance Analysis § 7).

Source layout

The feature is split across three files that all share one window.VanillaGridExcelExportInternals namespace object — each only adds helpers to it, so load order among them does not matter:

File Responsibility
features/excel-export.feature.js Coordinator — installs exportToExcel(options) on VanillaGrid.prototype; resolves rows, columns, number formats, widths and styles; runs the chunked producer; handles progress, Cancel, errors and delivery. On build.js's BUNDLE_FAST_PATH: its per-cell loop is a hot path.
features/excel-export-worker.feature.js The streaming .xlsx writer (a self-contained source string), the warm export Worker that runs it, the main-thread fallback that evaluates the same source, and the Worker-eligibility decision.
features/excel-export-delivery.feature.js iOS / Web-Share detection, file delivery (system share + <a download> blob), and the "Exporting…" overlay (progress, Cancel, dialog state).

API Reference

Core Grid (VanillaGrid)

grid.exportToExcel(options?)

Web Component (<vn-grid>)

document.querySelector('vn-grid').exportToExcel(options?)

Options Object

Option Type Default Description
fileName string 'export' Output file name without extension; .xlsx is appended.
sheetName string 'Sheet1' Worksheet tab name. Characters Excel forbids (: \ / ? * [ ], a leading or trailing ') become _, and the name is cut at 31 characters.
scope 'all' | 'selected' 'all' 'all' exports all loaded rows (displayRows, respects current sort/filter). 'selected' exports only the rows currently checked in the selection column.
columns string[] | null null Array of column keys to include. When null or omitted, all non-internal columns are included. Internal columns (__vgSelection__, __rowNumber__) are always excluded.
includeHeaders boolean true When true, the first row of the worksheet contains column headers.
autoFilter boolean true Excel AutoFilter on the header row. Only applies when headers are included.
formatCell function | null null Optional callback (value, column, rowData, rowIndex) => exportedValue. Return a new value to override the raw cell value, or undefined to keep it. Useful for formatting numeric IDs as strings to preserve leading zeros, converting booleans to "Yes/No", etc. Runs on the main thread, once per cell.
styleHeaderCell function | null null Optional callback (column, colIndex) => styleObject, once per header cell. See Cell Styling below.
styleDataCell function | null null Optional callback (value, column, rowData, rowIndex, colIndex) => styleObject, once per data cell, for conditional formatting (per-row or per-value styles). For uniform styling, prefer the static styles below.
headerCellStyle object | null null Optional static style object applied to every header cell. Ignored when styleHeaderCell is also provided.
dataCellStyle object | null null Optional static style object applied to every data cell (or to even-indexed rows when dataCellStyleAlt is also provided). Ignored when styleDataCell is also provided.
dataCellStyleAlt object | null null Optional static style object applied to odd-indexed data rows for alternating-row themes (e.g. Material, Fiori). When null, all data rows use dataCellStyle.
themeStyle 'auto' | VanillaGridExportPalette | false 'auto' Theme styling, on by default. 'auto' (the default) colours the sheet to match the grid's active theme (resolving a palette from --vn-grid-export-* CSS tokens → registerThemeExportStyle registry → built-in table → default). A palette object styles from that palette. false opts out for unstyled output. Resolved per style slot: an explicit headerCellStyle/dataCellStyle/dataCellStyleAlt wins only for that slot — any slot left unset still picks up themeStyle. See Excel Export Theming.
headersOnly boolean false When true, keep the resolved header style but force the data-cell styles to null. Column-level number formats and number/date alignment still apply. Applied after themeStyle is resolved.
useGridColumnWidths boolean true When true, column widths in the Excel file match the current pixel widths of the grid columns (converted to approximate character widths). When false, widths fall back to header label length.
useColumnTypeFormatting boolean true When true, columns are exported with Excel number/date formatting based on col.type and col.formatOptions: numbers stay numbers (with a number format), dates are written as Excel date serials with a date format, and numeric/temporal columns are right-aligned. When false, values are exported as they are.
useWorker boolean true When true (and the runtime supports Worker + Blob URLs), exports of >= workerThreshold rows write the workbook in a Web Worker. When false, the workbook is written on the main thread — still a chunk at a time, so the page stays responsive.
workerThreshold number 5000 Row count from which the workbook is written in the Worker. Override globally with window.VanillaGridExportWorkerThreshold.
overlayThreshold number 5000 Row count from which the "Exporting…" overlay (progress + Cancel) is shown. Override globally with window.VanillaGridExportOverlayThreshold. Infinity disables the overlay entirely, including the dialog that reports a failed export.

format is no longer an option: the export always produces .xlsx. A caller that still passes a value other than 'xlsx' gets an .xlsx file and one warning through the grid's logger.

Return Value and Errors

Returns a Promise<void> that resolves once the file has been handed to the browser (the download was triggered, or the share sheet opened). Callers that ignore the return value continue to work.

The Promise rejects with an Error carrying a code (declared as VanillaGridExportError in vanilla-grid.d.ts):

code When Extra fields
EXPORT_FAILED The workbook could not be written: a formatCell / style callback threw, the Worker failed, … cause — the underlying reason
EXPORT_TOO_LARGE A worksheet's XML would exceed the ZIP format's 4 GiB entry limit (only reachable with very wide sheets, about 90+ columns at a full 1,048,576 rows). rows, columns
EXPORT_CANCELLED The user pressed Cancel in the overlay.
EXPORT_IN_PROGRESS exportToExcel() was called while an export was already running on this grid.

A failure other than a cancel or a refused second call also switches the overlay to a dialog that says so, with the reason and a Close button (see Progress, Cancel and failures). <vn-grid-toolbar-export> handles the rejection itself: it ignores EXPORT_CANCELLED and EXPORT_IN_PROGRESS and logs the rest through the toolbar logger. A host that registers its own exportToExcel toolbar command should return the export's promise so the toolbar can do the same.


Header Label Resolution

Column headers are always resolved as human-readable labels. The resolution order is:

col.label  →  col.header  →  col.key

If col.secondaryLabel (the column's secondary header text) is non-empty, it is automatically appended in square brackets:

"Height"        →   no secondaryLabel          →   "Height"
"Height [m]"    →   col.secondaryLabel = 'm'   →   "Height [m]"
"Price [USD]"   →   col.secondaryLabel = 'USD' →   "Price [USD]"

This behaviour is fixed and not configurable — the exported file always uses the human-readable label.


Column Filtering

  1. Internal columns excluded: __vgSelection__ and __rowNumber__ are always filtered out.
  2. columns whitelist: If an array of column keys is supplied, only those columns are exported (still subject to the internal-column exclusion above).
  3. Applied group columns are composed back in, first: row grouping removes each applied group level's column from this.columns (its value lives in the caption row instead — see Row Grouping Implementation § 18). Exporting the visible array alone would therefore silently drop the very field the sheet is organised by. So the export column list is [...applied group columns in group order, ...visible columns] — matching the order the rows are already sorted in, and where a reader of a grouped grid expects to find them in column A. A group column already present among the visible columns (the degraded case where the grid cannot mount its group bar and keeps the column) is not duplicated.
    • An explicit options.columns allow-list stays authoritative and is never augmented this way.
    • The frozen-pane prefix count (below) runs against the same composed array, and a prepended group column is never counted into it: group columns sit ahead of the grid's own leading frozen run, so their presence breaks the frozen prefix outright (no frozen columns).
    • Caption rows, collapse state and group footer rows are all absent, structurally rather than by omission. The export walks displayRows — the ordered data — not renderEntries, which is where the grouping projection lives. A caption, a collapsed branch and a per-group total are things the projection has and the dataset does not, so there is nothing to filter out: the sheet is a flat, grouped-order data export, and it is the same sheet whether the grid was fully expanded or fully collapsed when the button was pressed. That is also what makes the totals recoverable rather than lost — with the group columns composed back into column A, Excel's own Data ▸ Subtotal over the exported sheet reproduces them as live formulas, which a pasted static number would not be. Re-implementing the footer rows as literal cells would hand the reader a worse artifact than the one Excel builds for itself.
  4. Column order: Export order otherwise follows the current this.columns order (which may have been modified by column reordering).

Data Type Handling

When useColumnTypeFormatting is true (the default), values are coerced to their native Excel types based on col.type:

col.type Written as Excel number format
'number' Number #,##0, or #,##0.000… with col.formatOptions.maximumFractionDigits (else minimumFractionDigits) decimals
'date' Date serial yyyy-mm-dd
'datetime' Date serial yyyy-mm-dd hh:mm:ss
'time' Date serial hh:mm:ss
(other) The raw value

A number column's value is parsed with parseFloat when it is not already a number; a temporal column's value is turned into a Date (a Date, epoch milliseconds or a parseable string) — values that do not parse are written as they are. Numeric and temporal columns are also right-aligned. The number format and alignment are defaults: a styleDataCell result that sets its own numFmt or alignment keeps it.

Date serials are local wall-clock time: days since 1899-12-30 plus the time of day as a fraction, the value Excel itself stores. A Date in a cell whose style carries no number format (a column that is not temporal, or useColumnTypeFormatting: false) is shown with Excel's built-in short-date format.

Other values: a JS boolean becomes an Excel boolean cell (any other value in a boolean column, e.g. 'Yes', stays text); null, undefined and '' produce an empty cell (which still carries its style, so fills and borders stay continuous); non-finite numbers produce an empty cell; any other object is written as its String().

Use the formatCell callback to override types — for example, format a numeric ID as a string to prevent Excel from stripping leading zeros:

grid.exportToExcel({
    formatCell(value, col) {
        if (col.key === 'employeeId') return String(value);
    }
});

Cell Styling

The static styles (headerCellStyle, dataCellStyle, dataCellStyleAlt, or a theme palette) are resolved once per export into one style per column and row band. The callbacks (styleHeaderCell, styleDataCell) are called once per cell on the main thread; their results are deduplicated (first by object identity per column, then by content), so a callback that returns a few shared objects costs little. Either way every distinct style becomes one entry of the workbook's cell-format table.

Style Object Format

{
    font: { bold: true, italic: false, underline: false, strike: false, color: { rgb: 'FFFFFF' }, name: 'Calibri', sz: 11 },
    fill: { patternType: 'solid', fgColor: { rgb: '667EEA' } },
    border: {
        top:    { style: 'thin', color: { rgb: 'E0E0E0' } },
        bottom: { style: 'thin', color: { rgb: 'E0E0E0' } },
        left:   { style: 'thin', color: { rgb: 'E0E0E0' } },
        right:  { style: 'thin', color: { rgb: 'E0E0E0' } },
    },
    alignment: { horizontal: 'center', vertical: 'center', wrapText: false },
    numFmt: '#,##0.00',
}

This is the full supported subset (typed as VanillaGridExportCellStyle); other properties are ignored:

Colours are { rgb: 'RRGGBB' } or { rgb: 'AARRGGBB' } (no #), or { theme, tint }.

Example: Conditional Row Colours

const odd = { fill: { fgColor: { rgb: 'F8F9FA' } } };
grid.exportToExcel({
    scope: 'selected',
    headerCellStyle: {
        font: { bold: true, color: { rgb: 'FFFFFF' } },
        fill: { fgColor: { rgb: '667EEA' } },
    },
    // Return shared objects: identical results are written once.
    styleDataCell: (_value, _col, _row, rowIndex) => (rowIndex % 2 ? odd : null),
});

Scope: All vs. Selected

scope value Data source Notes
'all' this.displayRows Includes all rows currently in the display (sorted/filtered). Does not include rows not yet loaded when using infinite scroll.
'selected' this.getSelectedRows() Rows with their selection checkbox ticked — including rows selected before a filter was applied that no longer appear in displayRows. getSelectedRows() reads from a selection-scoped cache (see docs/vanilla-grid/09-selection-implementation.md § 4/§ 5), not from the currently-loaded row set, so 'selected' exports are not limited to on-screen rows. Rows still visible in displayRows are ordered to match the current sort; any selected rows currently hidden by a filter are appended after them, in original selection order (see resolveRows() in excel-export.feature.js).

The number of rows is fixed when the export starts: rows appended while it runs (infinite scroll) are not included.


Dot-Path Column Keys

Cell values are resolved through column._getValue(rowData), the precompiled per-column accessor that setColumns() installs once on every column. Nested object paths declared as dotted keys/fields (address.city) work transparently in exports, and hosts can override the default accessor on a per-column basis with column.valueGetter(row).


Infinite Scroll Limitation

When infinite scroll is active, displayRows contains only the rows that have been loaded so far. The export will capture whatever is in memory at the time it is called. A future enhancement (scope: 'fetchAll') could pre-fetch all pages before exporting.


Column Width Hints

When useGridColumnWidths is true (the default), column widths in the Excel file are derived from the actual pixel widths of the grid columns at export time. Pixel widths are read from the <col> elements in headerColGroup and converted to Excel character widths using the approximation 1 character ≈ 7 px (Calibri 11pt at 96 DPI), with a minimum of 8 characters. This means the exported file closely mirrors the grid layout the user sees, including any manual column resizing.

When useGridColumnWidths is false (or no measurement is available), widths fall back to the header label length: max(label.length + 2, 10) characters.

Users can still resize columns in Excel after opening the file.


The Workbook

Feature How it is written
Header row Row 1 when includeHeaders is on, styled by the resolved header style.
Frozen panes The header row is frozen; so are the grid's leading frozen columns (see Column Filtering for when group columns cancel that).
AutoFilter On the header row, with its _xlnm._FilterDatabase defined name, unless autoFilter: false or there is no header row.
Strings Inline strings, XML-escaped; characters XML forbids (C0 controls other than tab, CR and LF) are removed; leading/trailing spaces are preserved; text longer than Excel's 32,767-character cell limit is cut there.
Rows past Excel's limit A worksheet holds 1,048,576 rows. Rows beyond 1,048,576 − header continue on sheets named <sheetName> (2), <sheetName> (3), …, each with its own header row, frozen pane and AutoFilter.
Compression Every part is deflated with CompressionStream('deflate-raw'). In a browser without it, the parts are stored uncompressed: the file is valid, only larger.

How an Export Runs

  1. Resolve (synchronous, cheap): the rows for scope, the exported columns, their number formats, alignments and widths, and the styles. Styles go into a registry that gives each distinct style one id; every cell is written with a style id.
  2. Produce (main thread): read the next chunk of rows — at most 5,000, and fewer once the chunk has taken 8 ms — calling column._getValue(row), formatCell and styleDataCell for each cell, and coerce the values (numbers, date serials, booleans, strings). Hand the chunk to the writer, then yield with VanillaGridYield.yieldToMainThread(). No whole-dataset copy is ever made.
  3. Write: the writer turns each chunk into worksheet XML, UTF-8 encodes it, updates the part's CRC-32 and pipes it through the native deflate stream, keeping only compressed bytes. At the end it writes the small package parts (content types, relationships, workbook, styles) and assembles the ZIP into a Blob.
    • From workerThreshold rows (default 5,000) the writer runs in the export Worker: one warm Worker for the page's lifetime, created by the first export that needs it; jobs are tagged with an id. Chunks are acknowledged one by one, and the producer waits whenever two are unacknowledged, which bounds memory to about two chunks of rows. The finished Blob comes back by reference.
    • Below the threshold, with useWorker: false, or when a Worker cannot be created, the same writer source runs on the main thread (evaluated once with new Function); the producer still yields between chunks.
    • A Worker that fails after it started is not retried on the main thread: the same work would fail the same way, only with the page frozen. The export rejects with EXPORT_FAILED.
  4. Deliver: a download, or the share sheet on iOS (see Delivery).

Under a Content Security Policy that forbids both blob: workers and eval-style code, neither path can run and the export rejects with EXPORT_FAILED.

Progress, Cancel and failures

The overlay is styled by vanilla-grid.css from the --vn-grid-export-overlay-* tokens each theme sets; its buttons (.vn-grid-export-overlay-action) take their colours from the overlay's own text colour.

Localized strings

Message key Default
exportingToExcel Exporting to Excel…
exportingToExcelProgress Exporting to Excel… {percent}%
exportCancel Cancel
exportClose Close
exportFailed The export failed.
exportTooLarge {rows} rows × {columns} columns is more than the export can produce in the browser. Filter the rows or select fewer, then export again.
exportReady Your file is ready.
exportSaveFile Save file

See Localization § 3.


Delivery


Sample App Integration

Every sample app puts the built-in export button in its toolbar's selected template:

<vn-grid-toolbar-export scope="selected" variant="ghost"></vn-grid-toolbar-export>

The button forwards { scope } to the standard exportToExcel command, which calls the linked grid's exportToExcel(); the grid's theme styling is automatic. Apps that want their own file name or options register a replacement command and return the export's promise, so the toolbar keeps handling its outcome:

toolbar.registerCommand('exportToExcel', (gridEl, arg) => {
    const headersOnly = window.matchMedia('(pointer: coarse)').matches;
    return gridEl.exportToExcel({ ...arg, fileName: 'orders-export', sheetName: 'Orders', headersOnly });
});

Security Notes