Import & Export

XLSX Export

A real workbook, written without a dependency: several sheets, styles, Excel's own conditional formatting rules, formula cells, and 200k rows for about 26 MB of heap because it writes in chunks.

Exporting a Grid

The visible columns, in their current order, filtered and sorted the way the reader left them. Sort or filter the grid below, then build the workbook and watch the byte count move.

A grid showing a pivot exports the crossing rather than the rows it folded away: the row fields, the generated columns, and the gathered rows as they stand, a total row included. A nested tier the reader has left shut is not written, since it is not on the grid either.

Share
Ordered on
DG-100
2
$40.00
5%
Mar 1, 2026
DG-210
7
$77.00
18%
Mar 4, 2026
DG-315
12
$114.00
31%
Mar 7, 2026
DG-420
6
$151.00
44%
Mar 10, 2026
DG-560
11
$188.00
57%
Mar 13, 2026
DG-675
5
$45.00
70%
Mar 16, 2026
6 rows
import {
  conditionalFormatting, createDataGrid, filtering, selection, sorting
} from '@sv5ui/datagrid';
import { buildGridXlsx, downloadGridXlsx } from '@sv5ui/datagrid/xlsx';

const grid = createDataGrid<Line>({
  columns,
  data: lines,
  getRowId: (line) => String(line.id),
  features: [
    sorting(),
    filtering(),
    selection(),
    conditionalFormatting({ rules: [{ kind: 'colorScale', column: 'price' }] })
  ]
});

// The visible columns, in their current order, filtered and sorted the way
// the reader left them.
await downloadGridXlsx(grid, { filename: 'orders.xlsx', sheetName: 'Orders' });

buildGridXlsx(grid, { sheetName: 'Orders', conditionalFormatting: true });
// Uint8Array, 6016 bytes over these six rows

// Narrow it:
await downloadGridXlsx(grid, {
  columnIds: ['sku', 'quantity', 'total'],   // and in this order
  selectedOnly: true,                        // only the ticked rows
  freezeHeader: true,                        // the header stays put on scroll
  autoFilter: true                           // Excel's own filter dropdowns
});

// Or take the bytes and do something else with them:
const bytes = buildGridXlsx(grid);           // Uint8Array
await fetch('/api/reports', { method: 'POST', body: bytes });

Types Carry Across

A column's type goes into the workbook, so a spreadsheet sorts and filters it as what it is rather than as text. That is the difference between an export you can pivot and one you cannot: the date column above lands as a real date serial, not as the string the cell drew.

const columns: ColumnDef<Line>[] = [
  { id: 'sku',       header: 'SKU' },
  { id: 'units',     header: 'Units',     type: 'number' },
  { id: 'price',     header: 'Unit price', type: 'currency' },
  { id: 'share',     header: 'Share',     type: 'percent' },
  { id: 'orderedOn', header: 'Ordered on', type: 'date' }
];

buildGridXlsx(grid, {
  value: (row, column) =>
    column.id === 'total' ? row.units * row.price : undefined
});

// A date lands as a real serial rather than the string the cell drew, which
// is the difference between an export you can pivot and one you cannot.

Styles

The grid applies a default from each column's type, and you can override at three levels: the header, a whole column, or a single cell with its value in hand.

BUILT_IN_STYLES

Read live from the export at runtime, so this table cannot drift from the package.

StyleNumber format
headerbold
dateyyyy\-mm\-dd
datetimeyyyy\-mm\-dd\ hh:mm
currency#,##0.00
percent0.00%
integer#,##0
import { BUILT_IN_STYLES } from '@sv5ui/datagrid/xlsx';

// The defaults, which the grid applies from a column's type:
//   header    bold
//   date      yyyy-mm-dd
//   datetime  yyyy-mm-dd hh:mm
//   currency  #,##0.00
//   percent   0.00%
//   integer   #,##0

buildGridXlsx(grid, {
  headerStyle: { font: { bold: true, color: '#FFFFFF' }, fill: '#1F2937' },

  columnStyle: (column) =>
    column.id === 'total' ? { font: { bold: true } } : undefined,

  // Per cell, so the style can read the value.
  cellStyle: (value, row, column) =>
    column.id === 'delta' && Number(value) < 0
      ? { font: { color: '#B91C1C' } }
      : undefined
});

// An XlsxStyle is font, fill, border, alignment and numberFormat. Colours are
// any CSS hex; they are converted to Excel's ARGB on the way in.

Conditional Formatting, as Excel's Own

Not as flattened colours. Open the file and the rules are still rules: they recalculate when a value changes. Excel's palette is not your theme's, so the colours are named separately.

DEFAULT_FORMAT_COLORS

Also read live. Pass an XlsxFormatColors of your own to change any of them.

KeyDefault
scaleFrom#FFFFFF
scaleTo#3B82F6
bar#638EC6
highlightFill#FFEB9C
highlightText#9C6500
// The rule has to be registered on the grid for there to be anything to
// carry across:
conditionalFormatting({ rules: [{ kind: 'colorScale', column: 'price' }] });

buildGridXlsx(grid, { conditionalFormatting: true });

// It exports as Excel's own rule, not as flattened colours: open the file
// and it still recalculates when a value changes.

// Excel's palette is not your theme's, so the colours are named separately:
import { DEFAULT_FORMAT_COLORS } from '@sv5ui/datagrid/xlsx';

buildGridXlsx(grid, {
  conditionalFormatting: {
    scaleFrom: '#FFFFFF',
    scaleTo: '#3B82F6',
    bar: '#638EC6',
    highlightFill: '#FFEB9C',
    highlightText: '#9C6500'
  }
});

// A rule the grid computes from whole-column statistics travels as the
// equivalent Excel rule, so the workbook recomputes it over the rows it holds
// rather than freezing what the browser worked out.

Large Exports

The sheet is written in chunks rather than assembled as one string, which is what keeps a large export inside the heap. Past a few thousand rows use the async builder: it yields between batches, so the tab keeps painting and a progress bar can move.

It also writes a much smaller file. The synchronous builder stores its entries; the async one deflates them, which is only worth doing off the main thread. Both are valid workbooks, so this is a reason to prefer the async builder even when blocking would not have mattered.

The button below builds the same 500 rows both ways and reports what each one weighed, so the comparison is measured in your browser rather than quoted from mine.

batchSize is 50 here, so onProgress reports ten times on the way through.
const bytes = await buildGridXlsxAsync(grid, {
  batchSize: 50,           // the demo above, so onProgress reports ten times
  onProgress: (loaded, total) => {
    done = loaded;
    expected = total;
  }
});

// Same 500 rows, both builders. The demo above measures this in your own
// browser rather than trusting the numbers written here:
buildGridXlsx(grid).length;             // 111_360, stored
(await buildGridXlsxAsync(grid)).length; //  16_331, deflated - about a seventh

await downloadGridXlsx(grid, { loadedOnly: true });

// The async builder yields between batches and deflates its entries, which
// is only worth doing off the main thread.

Exporting What the Server Has

A grid in server mode holds a hundred rows of five thousand, so "export the grid" is two different requests. The builders will not guess which one you meant: the synchronous one refuses outright, and the message names both ways forward.

loadedOnly: true writes what is on screen and says so. The async builder pages the data source itself, in batches, and reports as it goes: watch the request log fill while it walks the five thousand.

POST /datagrid/api/people ? rows on the server
Member
Team
Salary
Joined

No data

0 rows

The last few requests

  • Nothing yet. Export something.
import { buildGridXlsx, buildGridXlsxAsync, downloadGridXlsx } from '@sv5ui/datagrid/xlsx';

// The grid holds a hundred of five thousand rows, so "export the grid" is
// two different requests and the builders do not guess which one you meant.

// Refuses. Nothing in memory is the whole set, and writing a hundred rows
// into a file called everything.xlsx is the wrong kind of success.
buildGridXlsx(grid);
// Error: xlsx: this grid uses rowModel: "server", so only the rows currently
// held in memory are available synchronously. Use await downloadGridXlsx(grid),
// which pages the data source for the whole set, or pass { loadedOnly: true }
// to export just what is on screen.

// What is on screen, said out loud.
buildGridXlsx(grid, { loadedOnly: true });

// Or the whole set: this pages the data source itself, in batches, and
// reports as it goes.
await buildGridXlsxAsync(grid, {
  batchSize: 1000,
  onProgress: (loaded, total) => (progress = `${loaded} of ${total}`)
});

await downloadGridXlsx(grid, { fileName: 'people.xlsx', batchSize: 1000 });

// A selection is exempt too: selectedOnly means the rows you picked, which
// are by definition ones you have.

A Workbook With No Grid

createWorkbook takes columns and rows, and an array of them makes several sheets. Nothing about it knows what a grid is.

application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
import { createWorkbook, XLSX_MIME } from '@sv5ui/datagrid/xlsx';

// No grid involved. This is a workbook writer that a grid happens to use.
const bytes = createWorkbook({
  sheetName: 'Q1',
  columns: [{ header: 'SKU', width: 14 }, { header: 'Units' }, { header: 'Revenue' }],
  rows: [
    ['DG-100', 3, 121.5],
    ['DG-210', 7, 539]
  ],
  freezeHeader: true,
  autoFilter: true
});

// Several sheets: pass an array.
createWorkbook([
  { sheetName: 'Q1', columns, rows: q1 },
  { sheetName: 'Q2', columns, rows: q2 }
]);

// A sheet name Excel will not take is sanitized rather than rejected: the
// characters / \\ : * ? [ ] each become a space, and the name is clipped to
// Excel's 31.

new Blob([bytes], { type: XLSX_MIME });

Cells, Columns and Dates

The small helpers, for building a sheet by hand. Every value below is computed on this page rather than written into it.

columnLetter(0) / columnLetter(26)A / AA
cellRef(0, 0) / cellRef(9, 2)A1 / C10
toSerialDate(1900-01-01)2
toSerialDate(2026-01-01)46023
toSerialDate(2026-01-01 12:00)46023.5
import { cellRef, columnLetter, toSerialDate } from '@sv5ui/datagrid/xlsx';

columnLetter(0);    // 'A'
columnLetter(26);   // 'AA'
cellRef(0, 0);      // 'A1'   row is zero-based, the reference is not
cellRef(9, 2);      // 'C10'

// Excel counts days from 1899-12-30, and the time of day rides along as the
// fraction. Read from local parts, like every other date in the grid.
toSerialDate(new Date(1900, 0, 1));      // 2
toSerialDate(new Date(2026, 0, 1));      // 46023
toSerialDate(new Date(2026, 0, 1, 12));  // 46023.5

// A formula cell, rather than a value:
const rows = [['DG-100', 3, 40.5, { formula: 'B2*C2' }]];

ExportXlsxOptions

Everything buildGridXlsx, buildGridXlsxAsync and downloadGridXlsx accept.

OptionWhat it does
filename / sheetNameThe download's name, and the sheet's. A name Excel will not take is sanitized rather than rejected
columnIdsWhich columns, and in what order. Defaults to the visible ones as they stand
rows / selectedOnlySpecific nodes, or only the ticked rows
freezeHeader / autoFilterFreeze the header row, and give Excel its own filter dropdowns
valueWhat a cell contributes. Return undefined to leave it to the grid
headerStyle / columnStyle / cellStyleAn XlsxStyle for the header, per column, or per cell with the value in hand
conditionalFormattingtrue to carry the rules across, or an XlsxFormatColors to pick Excel's colours
loadedOnlyUnder a server row model, write the page the grid holds rather than fetching the rest
onProgress / batchSizeFor the async builder, so a large export can show its progress