Skip to content

Progress Overlay ​

Since 2.8.0 every export shows an optional-by-configuration full-screen overlay by default — no extra import, no wrapper function. Set overlay: false to turn it off entirely; pass an options object to customize it. The overlay UI is bundled into this package (it grew out of a standalone package before 3.0; the generic entry now lives on the /overlay subpath); this guide covers how the exporter drives it.

ts
import { exportExcel } from "@marcusok/excel-exporter";

const result = await exportExcel({
  filename: "sales-2026",
  sheets: [{ name: "Sales", columns, data }],
  // overlay: false,            // <- opt out entirely
  // overlay: { delayMs: 0 },   // <- customize
});

The overlay appears after a short delay, blocks page interaction, and is removed when the export settles — on success and on failure. In Node/SSR it is a no-op (there is no document).

exportTable and exportEcharts delegate to exportExcel, but their own option types (TableExportOptions / EChartsExportOptions) do not carry overlay and their converters drop the field — passing it there is a TypeScript error and is silently ignored at runtime, so the default overlay still shows. To control the overlay while starting from those data shapes, convert first with tableToSheet / echartsToSheet and call exportExcel yourself (see Table and ECharts entries).

Option values ​

ValueBehaviour
omitted / trueOverlay with the default export texts (default since 2.8.0).
falseNo overlay at all: nothing is mounted, no extra frame is yielded, callbacks run exactly as before the feature existed.
ProgressOverlayOptionsCustomize: texts, delayMs, theme, blockInteraction, … (all fields are listed in the option block below). Custom texts are merged with the export defaults — overriding text.title keeps the built-in stage labels, so phase keys like building never render as raw keys.
ts
await exportExcel({
  filename: "sales-2026",
  sheets: [{ name: "Sales", columns, data }],
  // `overlay` is a field of ExportOptions — every ProgressOverlayOptions
  // entry is customizable there. The values below are the built-in export
  // defaults; pass only what you want to change.
  overlay: {
    delayMs: 200, // don't show at all if the export finishes within 200ms
    minVisibleMs: 300, // once shown, stay at least this long (no flash)
    fadeOutMs: 150,
    zIndex: 2147483000,
    container: document.body,
    blockInteraction: true, // false = visual cover only
    theme: "auto", // "auto" | "light" | "dark"
    text: {
      title: "正在导出 Excel",
      initial: "准备中…",
      phases: {
        building: "正在构建工作簿…",
        downloading: "正在下载…",
        finishing: "即将完成…",
      },
      hint: "数据量较大时可能需要数十秒,请勿关闭页面",
    },
  },
});

text.hint is only rendered while the bar is indeterminate — a determinate bar shows its percentage instead.

Indeterminate vs determinate ​

Progress comes from the library's existing callbacks (onProgress / onPhase are chained, never replaced — a metrics panel wired to the same callbacks keeps working), so the overlay is only as good as the data behind it:

RouteConditionIntermediate progress
main + Workbookbrowser < 20,000 rowsnone
main + Fast streamNode (auto ≥ 50,000 rows, or explicit stream — Node has no Worker)every 1,000 rows
Worker + Workbookauto, 20,000–49,999 rowsnone
Worker + Fast streamauto ≥ 50,000 rows, or explicit stream in the browserevery 1,000 rows

Only the Fast stream path emits values between 0 and 1 (onProgress). The overlay therefore renders an animated spinner until the first intermediate value arrives, then switches to a determinate bar. Workbook routes stay indeterminate for their whole run — that is the granularity of the data source, not a rendering problem.

The trailing onProgress(1) never closes the overlay and never fabricates a completed bar on a route that had no real progress; closing is driven by the promise settling.

Blocking the main thread ​

WorkbookBuilder.addSheet and the Fast stream writer are synchronous. While they run the browser cannot repaint, which constrains how the overlay can behave:

  • With the default delayMs: 200, if a blocking span is already in progress when the delay expires, the reveal is starved: the overlay never appears for that export. This is why a small, warm (already-WASM-initialized) export on the main route shows no overlay at all — the build starts before the delay expires and finishes under it. A late timer is explicitly cleared on close, so it can never pop up after the export finished either.
  • With delayMs: 0 the overlay is mounted synchronously before exportExcel runs its build, and a two-frame yield (nextPaint) lets the browser paint it first. This is the only way to see the overlay on a blocking route — at the cost of a brief flash on fast exports.

Either way, the spinner keeps spinning during a block (it is a CSS transform animation, driven by the compositor), while the percentage and label freeze until the thread is free again.

Driving the overlay yourself ​

For a custom flow around the low-level entry points, drive the overlay directly through the /overlay subpath — the exporter's overlay option is exactly this protocol with export-flavored texts:

ts
import { showExportOverlay } from "@marcusok/excel-exporter/overlay";

const overlay = showExportOverlay({
  text: { title: "正在导出 Excel", phases: { building: "正在构建工作簿…" } },
});
try {
  // exportAsStream's second argument is the callback itself, not an options object
  return await exportAsStream(sheets, (p) => overlay.setProgress(p));
} finally {
  overlay.close(); // idempotent
}

Legacy subpath ​

@marcusok/excel-exporter/overlay (pre-2.8) still exists, and since the overlay no longer ships as a separate package it is the supported way to drive the overlay outside an export: exportExcelWithOverlay(options, overlay?) is a thin wrapper equivalent to exportExcel({ ...options, overlay }), showExportOverlay is the generic overlay entry, and nextPaint is exported alongside it for the mount-then-yield pattern (see Blocking the main thread). Note the text shape changed back when the feature moved into the library: the old flat fields (text.building, text.downloading, …) are now a text.phases map, and the handle methods are setProgress / setPhase(key) instead of handleProgress / handlePhase.

Concurrency ​

Overlays share one DOM node, reference-counted: concurrent exports render into the same overlay (last writer wins) and it is removed when the last one closes. exportTable/exportExcel are safe to call concurrently; nothing leaks between runs. The rendered content (title, hint, label, progress) always belongs to the most recent show / progress / phase event's caller.

Node and SSR ​

Without a document, the overlay is a no-op handle and exportExcel exports normally — the same call site works in both environments, no branching required.

Accessibility ​

The bar carries role="progressbar" (with aria-valuenow only while determinate), the label is an aria-live="polite" status region, and the container sets aria-busy. prefers-reduced-motion: reduce disables the spinner animation and all transitions.

Note that blocking is implemented at the pointer level, and the overlay deliberately does not apply inert to the page beneath it — keyboard users can still tab to elements that are visually covered. Applying inert to every sibling node is invasive enough to risk breaking host-page behaviour, so it is left out.

See also ​