Advanced Features
Multiple sheets
sheets is an array — one call produces a multi-page workbook:
await exportExcel({
filename: "department-report",
sheets: [
{ name: "Sales", columns: [...], data: salesData },
{ name: "Staff", columns: [...], data: staffData },
],
});Sheet names must satisfy ECMA-376 constraints: non-empty, ≤ 31 characters, must not contain : \ / ? * [ ], and must not begin or end with an apostrophe. They must also be unique across sheets — a duplicate name fails the same way instead of producing a corrupt file or a silently renamed sheet. A violation never produces a corrupt file and never throws to the caller: input is validated up front, before any export route (or the fallback chain) runs, and the call resolves with { success: false, error } carrying a clear message.
Frozen rows
freezeRows: 1 freezes the header row (mapped to frozenPane). For multi-row headers, prefer freezeRows >= header row count so all header rows stay visible.
Multi-row headers
Nest columns with children to get multi-row headers. Group header cells merge across all their descendant leaf columns, and leaf headers span the remaining header rows vertically — no manual merge math required.
await exportExcel({
filename: "monthly-sales",
sheets: [
{
name: "Sales",
freezeRows: 3,
columns: [
{ prop: "product", label: "Product" },
{
label: "Revenue",
children: [
{
label: "This month",
children: [
{ prop: "m_qty", label: "Qty" },
{ prop: "m_amt", label: "Amount" },
],
},
{
label: "YTD",
children: [
{ prop: "y_qty", label: "Qty" },
{ prop: "y_amt", label: "Amount" },
],
},
],
},
],
data: [{ product: "A", m_qty: 1, m_amt: 2, y_qty: 3, y_amt: 4 }],
},
],
});This produces a 3-row label:
| Row | A | B | C | D | E |
|---|---|---|---|---|---|
| 1 | Product | Revenue (merged B–E) | |||
| 2 | This month (merged B–C) | YTD (merged D–E) | |||
| 3 | Qty | Amount | Qty | Amount |
Rules:
- Leaf columns (no
children) need aprop(or legacykey); group columns may omit it and contribute header rows only; width/style/formatapply to leaf columns only;- Group header cells style via that column's
headerStyle, leaf headers likewise (falling back to the sheet-levelheaderStyle); - Multi-row headers work on every path (main / worker / stream, including the stream fallback); merges survive on the stream and fallback paths too (styles excepted).
Try it live: below is a mock preview of the sales-grouped dataset (two-level grouped header, matching the exported file's header structure); pick the sales-grouped dataset in the playground demo panel to export a real file with a multi-row header and data-area merges.
| Order | Item | Amount | Status | |||||
|---|---|---|---|---|---|---|---|---|
| Order ID | Date | Region | Product | Channel | Qty | Unit Price | Amount | |
| ORD-000001 | 2026-07-21 | 华东 | USB-C 扩展坞 | 线上 | 18 | 789.44 | 14209.92 | pending |
| ORD-000002 | 2026-07-28 | 西南 | 显示器支架 | 线上 | 5 | 1127.47 | 5637.35 | paid |
| ORD-000003 | 2026-07-15 | 东北 | 机械键盘 | 线下 | 1 | 898.61 | 898.61 | paid |
| ORD-000004 | 2026-07-25 | 华南 | 机械键盘 | 线上 | 4 | 360.06 | 1440.24 | refunded |
| ORD-000005 | 2026-07-14 | 华东 | 机械键盘 | 线下 | 7 | 643.8 | 4506.6 | paid |
Merged cells
{
name: "Summary",
columns: [...],
data: [...],
merges: [
{ row: 0, col: 0, rowspan: 1, colspan: 2 }, // first data row spans two columns
],
}MergeRange is relative to the data area: row / col start at 0 (row 0 = first data row); rowspan / colspan are the spans.
Merges are validated identically on every path: values must be integers, row/col ≥ 0, rowspan/colspan ≥ 1, the range must stay inside the data area (leaf column count / data row count), and ranges must not overlap each other. Invalid input resolves with { success: false, error } naming the offending merge — never a workbook Excel flags as corrupt.
Auto filter
autoFilter: true adds filter dropdowns to the last header row, spanning that row plus every data row beneath it (Excel's own filter semantics).
Progress and phase callbacks
await exportExcel({
...,
onProgress: (progress) => {
// 0 → 1; the leading 0 and trailing 1 fire exactly once each on every route
// (the stream fallback included); incremental progress only on the stream
// path (every 1000 rows)
bar.style.width = `${progress * 100}%`;
},
onPhase: (phase, durationMs) => {
// phase: "init" | "build" | "download", strictly sequential
console.log(`${phase} took ${durationMs.toFixed(1)}ms`);
},
});Phase semantics:
| Phase | Description |
|---|---|
init | WASM init; reported on every main-path export (~0ms once loaded); Node's main-thread stream path and the main-thread stream fallback do not load WASM but still report a single 0ms to keep the phase sequence stable; on Worker + Workbook only when the worker initializes; not reported on Worker + stream |
build | Workbook construction (reported once per actual attempt, including fallback) |
download | Browser download trigger (absent with download: false; absent in Node) |
onPhasemeasures per-phase wall time only;ExportResult.durationmeasures the whole export on main-thread routes. On the worker route it is measured on the main thread from the call into the worker — including the pre-post serialization and the worker round-trip, through Blob construction — so it is wider than the pure in-worker build time.
For a ready-made full-screen overlay built on these two callbacks, see Progress Overlay.
Disable auto download
const result = await exportExcel({ ..., download: false });
// use result.blob directlyExport result
interface ExportResult {
success: boolean;
blob?: Blob;
engine?: "modern-xlsx"; // engine actually used
mode?: ExportMode; // mode actually used
duration?: number; // total export duration in ms
rowCount?: number;
error?: Error;
}Show result.error on failure; on a successful export a non-empty result.error marks a degraded (style-less stream) export.