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
- Internal columns excluded:
__vgSelection__and__rowNumber__are always filtered out. columnswhitelist: If an array of column keys is supplied, only those columns are exported (still subject to the internal-column exclusion above).- 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.columnsallow-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 — notrenderEntries, 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.
- An explicit
- Column order: Export order otherwise follows the current
this.columnsorder (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:
font:bold,italic,underline,strike,sz,name,color;fill:patternType(defaults to'solid'whenfgColoris set),fgColor,bgColor;border:left,right,top,bottom, each{ style, color }(thin,medium,thick,dashed,dotted, …);alignment:horizontal,vertical,wrapText,indent,textRotation;numFmt: an Excel format code, or a built-in format id (number).
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
- 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. - 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),formatCellandstyleDataCellfor each cell, and coerce the values (numbers, date serials, booleans, strings). Hand the chunk to the writer, then yield withVanillaGridYield.yieldToMainThread(). No whole-dataset copy is ever made. - 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
workerThresholdrows (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 finishedBlobcomes 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 withnew 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.
- From
- 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
- Overlay. From
overlayThresholdrows (default 5,000) an overlay covers the grid for the duration of the export: a spinner, a label that reports progress ("Exporting to Excel… 42%", updated at most every 250 ms), and a Cancel button. The button is keyboard-reachable but not focused automatically, so starting an export does not move focus. The box is sized from the start for the widest label, theexportingToExcelProgressmessage at 100% (kept in an invisiblearia-hiddencopy that shares the label's grid cell), so it does not grow as the percentage gains digits. - Cancel stops the producer and the writer (the Worker drops the job and stays warm), closes the overlay, delivers no file, and rejects with
EXPORT_CANCELLED. It works on both paths: user input is handled between chunks even when the whole export runs on the main thread. - One export at a time per grid: a call while an export runs rejects with
EXPORT_IN_PROGRESS. - Failure dialog. When an export fails for any other reason, the overlay (created now if the export was below the threshold) switches to a dialog: the localized message (
exportFailed, orexportTooLargewith the row and column counts formatted by the grid'sformatInteger), a Close button that takes focus,role="alertdialog"labelled by the message, and Escape to close. ForexportFailedthe reason (cause, a browser or host-callback error that no locale bundle can translate) is shown as-is on a smaller, muted line under the message (.vn-grid-export-overlay-detail); it describes the dialog (aria-describedby) and can be selected, so it can be copied into a support request.overlayThreshold: Infinityturns the dialog off along with the overlay; the host then handles the rejection.
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
- Desktop browsers: a synthetic
<a download="<fileName>.xlsx">click on aBlobURL, revoked a second later. - iOS / iPadOS (Safari and every other iOS browser, which are WebKit-based):
<a download>on a blob URL is unreliable there (it navigates to the blob, or saves under a GUID name), so the file goes through the Web Share API:navigator.share({ files: [new File([blob], '<fileName>.xlsx')] }), which opens the system share sheet with the correct file name (save to Files, AirDrop, Mail, …).titleis deliberately not passed: Firefox for iOS turns it into a second shared item.- If the user dismisses the sheet (
AbortError), nothing else happens. share()requires a recent user gesture. After a long export the gesture that started it may have expired, andshare()rejects withNotAllowedError. The overlay then shows Your file is ready. with a Save file button; tapping it callsshare()again with a fresh gesture.- Any other share failure is logged as a warning.
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
- The workbook is produced entirely client-side — no data leaves the browser.
- No third-party code is involved: the writer is part of the grid.
- The
formatCelland style callbacks are caller-provided and run in the same origin — no injection risk beyond what the host application already accepts. - Cell text is written as inline strings, never as formulas, so text beginning with
=is shown as text, not evaluated.