# Sheetwrite — full documentation text > Sheetwrite is a framework-agnostic, high-performance spreadsheet grid: a canvas grid backed by a Rust/WASM columnar engine, with an imperative core and React, Vue, and Svelte adapters. Every authored guide in the Sheetwrite documentation, followed by every compiler-verified public API declaration. Generated reference tables (API pages, formula functions, compatibility results, entry-point inventory, performance evidence) are published as HTML and are linked from https://sheetwrite.vercel.app/llms.txt instead of being repeated here. Packages: 0.4.0. Canonical HTML: https://sheetwrite.vercel.app/docs/ ## Contents - [Runtime ownership](#runtime-ownership) - [Adapter lifecycle](#adapter-lifecycle) - [React](#react) - [Svelte](#svelte) - [Vanilla JavaScript](#vanilla-javascript) - [Vue](#vue) - [Accessibility](#accessibility) - [Collaboration and offline work](#collaboration-and-offline-work) - [Configuration](#configuration) - [Data operations and export](#data-operations-and-export) - [Formulas](#formulas) - [Keep app rows in your store](#keep-app-rows-in-your-store) - [Interaction and editing](#interaction-and-editing) - [Persistence and recovery](#persistence-and-recovery) - [Styling and theming](#styling-and-theming) - [Worker rendering](#worker-rendering) - [XLSX and export](#xlsx-and-export) - [Public API contract](#public-api-contract) - [Compatibility and limits](#compatibility-and-limits) - [Document operations](#document-operations) - [Error handling](#error-handling) - [Your first grid](#your-first-grid) - [Installation](#installation) - [Public API declarations](#public-api-declarations) ## Authored guides ### Runtime ownership Source: https://sheetwrite.vercel.app/docs/concepts/runtime-ownership/ [Installation](https://sheetwrite.vercel.app/docs/start/installation/) ## Architecture at a glance Sheetwrite splits cleanly into a **data engine** (Rust → WASM) and a **renderer** (canvas). The TypeScript core (`@sheetwrite/core`) owns the host DOM, the scroll/virtualization math, interactions, and the bridge between the two. The renderer never owns document cells. Each frame requests one bulk resolved window per painted pane; an unfrozen sheet uses one window, while frozen panes use up to four clipped windows. `GridImpl` is the composition root rather than the owner of every subsystem. `DocumentController` owns document commits and history, `DatasourceController` owns paged request generations and cancellation, `GeometryLayoutController` owns row/column indexes and frozen-window mapping, and `RenderCoordinator` is the sole animation-frame and paint-cache owner. Interaction-specific controllers remain separate. These are internal ownership boundaries; the public API stays the `Grid` and `Store` contracts. ## Canvas rendering and the WASM columnar store Cells live in the WASM module as typed columns, not as objects. Each cell has a kind—`EMPTY=0`, `NUMBER=1`, `STRING=2`, `BOOLEAN=3`, or `FORMULA=4`—and numeric/string data is held in parallel typed arrays. This keeps memory flat and lets the store hand the renderer a transferable, typed-array-backed window. Because the engine is columnar and WASM-resident: - A sheet of 100k+ rows costs no DOM nodes and no per-cell JS objects. - The render hot path is a single bulk read, not N cell lookups. - The same snapshot can be transferred to a worker for off-thread painting (see [Worker rendering](https://sheetwrite.vercel.app/docs/guides/worker-rendering/)). ## Virtualization & scaled scroll Only the rows intersecting the viewport are ever painted. `computeWindow` turns the current scroll offset and viewport height into a half-open row range `[start, end)`, padded by `overscan` rows on each side so a fast scroll reveals already-painted rows: ```ts declare function computeWindow(index: unknown, contentTop: number, viewportHeight: number, overscan: number): { start: number; end: number }; ``` Row positions come from an `OffsetIndex` — a Fenwick (binary-indexed) tree over row heights giving `O(log n)` scrollTop ↔ row mapping and `O(log n)` single-row height edits, so variable row heights stay cheap. Browsers clamp an element's height at an engine-specific ceiling — Chromium sits near 33.5 million CSS pixels at 100% zoom, and the ceiling **shrinks with browser zoom and `devicePixelRatio`**. At the default 28px row height that can be barely ~1M rows, so Sheetwrite never trusts a constant: it measures the real clamp with a probe element (re-measuring on zoom changes), pins the DOM sizer to that cap, and `ScaledScroll` maps the capped scrollbar position linearly onto the real virtual range: ```ts // DOM scrollTop -> content-space offset (identity below the cap) declare function toContent(scrollTop: number): number; // content-space offset -> DOM scrollTop declare function toScroll(contentOffset: number): number; ``` Below the cap the mapping is the identity; above it, scrolling stays smooth while the few-pixel-per-row resolution loss is invisible at that scale. ## The Store contract A grid talks to its data through the `Store` interface. The two reads are deliberately different: | Method | Use | | --- | --- | | `getVisibleWindow(sheet, rows, cols)` | The **render hot path**. Returns one `VisibleWindowView` for a whole rectangle. The renderer paints from this and must never read cell-by-cell. | | `getCell(addr)` | A **single-cell** read for interactions, API reads, and tests — never per frame. Returns `{ resolved, style }`. | `VisibleWindowView` is backed by typed arrays (`values`, `styleIds: Uint32Array`, a shared `styles` dictionary) so it is worker-transferable, and it is valid only until the next store mutation or window refresh. The full interface: ```ts interface StoreContract { getWorkbook(): Workbook; getCell(addr: CellAddress): ResolvedCell; getVisibleWindow( sheet: SheetId, rows: { start: number; end: number }, cols: readonly number[], ): VisibleWindowView; applyTransaction: Store["applyTransaction"]; on: Store["on"]; acknowledgeOperations?(operations: readonly DocumentOp[]): void; } ``` ## Document protocol and storage transactions `WorkbookSnapshot` is the versioned, JSON-safe authoritative document. Schema 1 contains sheet order and identity, stable column keys, literal/formula/reference cell inputs, sparse styles and row metadata, merges, frozen panes, conditional formats, validation rules, protected ranges, notes, row groups, hidden document metadata, filters, sort keys, and named ranges. Formula source is authoritative; resolved values are derived caches and are not serialized. `DocumentOp` is the exhaustive plain-data mutation vocabulary for that document. Cells, ranges, rows/columns, merges, row metadata, frozen panes, conditional formats, validation, protection, notes, row groups, named ranges, and sheet lifecycle operations use the same store reducer and change-event shape. Local operations submitted through `Grid` enter grid undo/redo history; `SyncCoordinator` observes each non-empty local transaction and owns its durable mutation-ID record. Direct `Store.applyTransaction` calls deliberately bypass grid read-only/history policy. Remote operations remain observable with `source: "remote"` but enter neither local history nor outgoing synchronization. ```ts const checked = validateWorkbookSnapshot(JSON.parse(payload)); if (!checked.ok) throw new Error(checked.errors[0]?.message); const outcome = grid.store.applyTransaction({ patches: [ { op: "set", addr, value: { kind: "literal", value: "authoritative input" }, style: { backgroundColor: "#fde68a" }, }, ], }); ``` Snapshot hydration is a separate, validated path: it creates every sheet handle, bulk-loads literal runs, installs formula/reference/style exceptions, then recomputes after the complete graph exists. It emits no user event, dirty patch, or undo entry. Export uses one bulk data-order read per sheet, ignores local sort/filter views and conditional render styles, and emits deterministic sparse blocks. ```ts const saved = await adapter.load(documentId, signal); const grid = createGridFromSnapshot(host, saved); const sync = new SyncCoordinator(grid, adapter, { documentId, serverVersion: saved.version ?? 0, }); await sync.ready(); const unsubscribeRemote = sync.subscribe(remoteOperationSource); await sync.sendNext(); // host chooses send/retry timing ``` Each local grid transaction becomes one immutable `PendingCommit` with a stable `clientMutationId` and explicit `baseVersion`. Records move from `pending` to `sending`; an applied or duplicate response acknowledges store dirty state and removes only the matching ID. Transport failure returns that record to `pending`, so retry sends the same ID. A conflict remains `conflicted` with its local operations intact and exposes server operations or a snapshot; Sheetwrite does not silently merge ambiguous structural edits. See [Offline and collaboration](https://sheetwrite.vercel.app/docs/guides/collaboration/) for durable storage, recovery, presence, comments, revisions, and the conservative rebase gate. Remote operations must arrive at exactly `serverVersion + 1`. Older versions and known mutation echoes are idempotently ignored; a gap emits `reload-required` instead of guessing. Canonical server operations apply with source `remote`, so they repaint/recompute without entering the outgoing queue. Document state does **not** include selection, scroll position, editor/caret state, search results, temporary highlights, renderer choice, read-only policy, or local zoom. Those are session state. `Workbook.activeSheet` remains document metadata in schema 1. Validation and protection run at the shared local transaction boundary, not only inside the inline editor. Validation policies can reject atomically, apply with a warning, or allow input. Protected ranges deny local writes unless the host's `ProtectionResolver` allows them; the default is deny. `atomic` mode rejects a transaction containing any denied operation, while `partial` mode filters denied operations and reports them in `rejections`. Protection is client UX policy, not server authorization. Remote operations intentionally bypass local protection. Applied storage transactions emit `change` with the filtered transaction, per-cell rollback data, accumulated dirty patches, epoch, commit reason, and an explicit `source` (`local` or `remote`). Remote operations still update formulas and rendering, but do not re-enter outgoing persistence. Conflicts and no-ops return explicit outcomes rather than throwing. Sheet IDs remain stable across rename and reorder. Renaming rewrites canonical cross-sheet formula source while preserving the stable formula-engine handle. Removing a referenced sheet rewrites dependents to `#REF!`; undo restores the sheet snapshot, formulas, styles, metadata, and dependencies. Removing the active sheet selects the nearest surviving sheet. The final sheet cannot be removed. All public document actions, including custom calls through `grid.actions`, are no-ops when the grid is read-only. ### Cell values A cell value is one of three shapes: ```ts type CellValue = | { kind: "literal"; value: string | number | boolean | null } | { kind: "ref"; target: CellAddress } // a plain cross-reference | { kind: "formula"; src: string }; // authoritative source; "=" is optional at storage ``` ## Headers: letters vs. field names The grid chrome is a real spreadsheet: the top band shows **column letters** (A, B, C, … AA) centered, and the left gutter shows **row numbers** (its width is `theme.rowHeaderWidth`; set it to `0` to hide the gutter). These letters are the column headers the renderer draws — the `header` you set on a `Column` is metadata (it is what CSV/XLSX export writes), not the on-canvas header. Named **field** titles are therefore not chrome. The convention, exactly as in a real sheet, is to put them in **row 0** (the first data row) and style them yourself. The live [Vanilla example](https://sheetwrite.vercel.app/vanilla/) reserves row 0 and writes a styled header band over it once the initial page loads: ```ts const FIELD_HEADERS = ["ID", "Date", "Customer", "City", "Amount"]; function applyFieldHeader(): void { grid.store.applyTransaction({ patches: FIELD_HEADERS.map((label, col) => ({ op: "set", addr: { sheet: "sales", row: 0, col }, value: { kind: "literal", value: label }, style: { bold: true, align: "center", backgroundColor: "#eef1f5" }, })), }); } ``` Datasource pages can carry the same authoritative `CellValue` shapes as eager data, plus `{ value, style }` wrappers. Formula sources, references, and styles hydrate without entering dirty history. If row 0 is styled locally while a page is outstanding, the local edit wins over that stale response; reserving an empty row 0 in the source remains useful when the source itself should never provide a title row. ## See also - [Configuration](https://sheetwrite.vercel.app/docs/guides/configuration/) for the option and method reference. - [Data operations](https://sheetwrite.vercel.app/docs/guides/data-operations/) for sort/filter views built on the same window read. ### Adapter lifecycle Source: https://sheetwrite.vercel.app/docs/frameworks/lifecycle/ Every adapter follows one lifecycle: 1. mount the framework component; 2. initialize the shared WASM runtime; 3. create one `Grid` generation; 4. publish readiness; 5. reset only when structural ownership changes; 6. destroy the generation on unmount. Pick a framework in any tab group below; the selection is remembered and reused across documentation pages. ## Pick the ownership model Use **`Sheetwrite`** for a data-first, uncontrolled grid: pass columns plus initial rows and let the component own the live document. Use **`SheetwriteGrid`** for an advanced `Workbook` plus eager data or a datasource. It exposes the imperative `Grid` after readiness and keeps structural configuration under the host's control. **Vanilla** The imperative core has one ownership mode: the host supplies the workbook and data, awaits runtime initialization, and destroys the returned grid. ```ts import { createGrid, initSheetwrite } from "@sheetwrite/core"; await initSheetwrite(); const grid = createGrid(host, { workbook, data }); // Later, when the host DOM lifetime ends: grid.destroy(); ``` **React** ```tsx observe(grid, generation, reason)} /> ``` ```tsx connect(grid)} /> ``` `defaultRows` is seed data. Changing it after mount does not replace committed edits; a new `columns`, `defaultRows`, or `sheetName` identity derives a new document and creates a new generation. **Vue** ```vue ``` ```vue ``` The template ref exposes `{ grid }` after readiness. `defaultRows` seeds the document once; a new `columns`, `defaultRows`, or `sheetName` identity creates a new generation. **Svelte** ```svelte ``` ```svelte ``` `bind:grid` publishes the live handle and clears it on reset or unmount. `defaultRows` seeds the document once; a new `columns`, `defaultRows`, or `sheetName` identity creates a new generation. ## Readiness is generation-scoped `onReady` receives `{ grid, generation, reason }`. `generation` is one-based and increments whenever the adapter replaces its `Grid`. `reason` is exactly one of: - `"initial"` — the first generation after mount and WASM initialization; - `"input-reset"` — a reset-classified document input changed identity; - `"renderer-reset"` — `renderer`, `workerUrl`, or `renderers` changed identity. The exposed handle (React ref, Vue `expose`, Svelte `bind:grid`) is assigned before `onReady` runs and cleared when that generation is destroyed. Reattach subscriptions and collaboration coordinators inside `onReady`; never retain a stale `Grid` across a reset. ## Controlled values do not reset the document `theme`, `readOnly`, `config`, `overscan`, and `minColumns` are live: changing them updates the current generation in place without recreating the grid or replaying `defaultRows`. Callbacks and host sizing (`height`, `fill`, `className`/`class`, `style`) are also safe to change live. Structural changes are different. Every reset-classified input is compared by identity: | Change | Result | | --- | --- | | `workbook`, `data`, `datasource`, `datasourceStorage`, `presentation` | New generation, `reason: "input-reset"` | | `editors`, `protectionResolver`, `mutationPolicy`, `transactionResourceLimits` | New generation, `reason: "input-reset"` | | `renderer`, `workerUrl`, `renderers` | New generation, `reason: "renderer-reset"` | | `columns`, `defaultRows`, `sheetName` (simple component) | New derived document, `reason: "input-reset"` | | `theme`, `readOnly`, `config`, `overscan`, `minColumns` | In-place update, no reset | Keep reset-classified inputs referentially stable (module constants, memoized values, or state) unless you intend to create a new generation. ## Transactions and persistence `onGridChange` receives committed transactions. `transaction.patches` is the exhaustive document operation list; `changes` is the cell-level compatibility view. ```ts function persist(event: ChangeEvent): void { if (event.source === "local") operationQueue.push(...event.transaction.patches); } ``` Use `event.reason` for audit context, not to reconstruct the transaction. For offline durability and replay, persist operations with `SheetwriteStore` or your own `PersistenceAdapter`. ## Initialization failures Framework components surface initialization errors instead of rendering a dead grid. React uses `fallback` and `onInitializationError`; Vue and Svelte support fallback content or slots plus their error callback/event. WASM initialization is process-wide and monotonic: every adapter converges on the same runtime, and when several pass an explicit `wasmSource` concurrently, the first source wins. While initialization has not yet succeeded, a changed `wasmSource` or a remount retries it; after the runtime is ready, the source is fixed for the process. ## Server rendering Importing an adapter during SSR is safe, but the grid initializes only after a browser mount. Put it behind the host framework's client boundary: - React/Next.js: a client component; - Nuxt: ``; - SvelteKit: a browser-only branch or client-mounted component. No framework owns the global runtime. All adapters converge on the same monotonic initializer. ### React Source: https://sheetwrite.vercel.app/docs/frameworks/react/ `@sheetwrite/react` initializes WASM after client mount. Use `Sheetwrite` for the simple `columns` and `defaultRows` contract; use `SheetwriteGrid` when the host supplies a `Workbook` plus eager data or a datasource. `onGridChange` reports committed grid changes. `onReady` receives `{ grid, generation, reason }` after the forwarded ref is assigned. Replacing reset-sensitive inputs creates a new generation; destroy persistence/sync coordinators attached to the previous grid. ```tsx import { Sheetwrite, type GridReadyEvent, type SimpleColumn } from "@sheetwrite/react"; import type { Transaction } from "@sheetwrite/core"; import "@sheetwrite/react/styles.css"; type Row = { name: string; amount: number }; interface SheetProps { columns: readonly SimpleColumn[]; rows: readonly Row[]; save: (transaction: Transaction) => void; onReady: (event: GridReadyEvent) => void; } export function Sheet({ columns, rows, save, onReady }: SheetProps) { return ( save(event.transaction)} onReady={onReady} /> ); } ``` Server rendering the component does not initialize WASM. Mount it in a client component in frameworks that render on the server. - [Run the React example](https://sheetwrite.vercel.app/react/) - [Lifecycle and reset semantics](https://sheetwrite.vercel.app/docs/frameworks/lifecycle/) - [`@sheetwrite/react` API](https://sheetwrite.vercel.app/docs/api/react/) ### Svelte Source: https://sheetwrite.vercel.app/docs/frameworks/svelte/ `@sheetwrite/svelte` initializes WASM in the browser. Bind `grid` when the host needs imperative access and pass `onGridChange` or `onReady` callbacks using the adapter's camel-case props. The binding is populated before `onReady` and cleared before reset or unmount. A reset publishes a new generation and reason; coordinators attached to the previous grid must be destroyed and recreated. ```svelte ``` Use a client-only boundary in SvelteKit when the surrounding route is server rendered. - [Run the Svelte shell example](https://sheetwrite.vercel.app/svelte/) - [Lifecycle and reset semantics](https://sheetwrite.vercel.app/docs/frameworks/lifecycle/) - [`@sheetwrite/svelte` API](https://sheetwrite.vercel.app/docs/api/svelte/) ### Vanilla JavaScript Source: https://sheetwrite.vercel.app/docs/frameworks/vanilla/ Use `@sheetwrite/core` when the host owns DOM lifetime directly. Call `initSheetwrite()` on the client, pass an existing element to `createGrid`, and retain the returned `Grid` for events and commands. ```ts import { createGrid, initSheetwrite, type ChangeEvent, type ColumnarData, type Workbook, } from "@sheetwrite/core"; export async function mountSheetwrite( host: HTMLElement, workbook: Workbook, data: ColumnarData, persistChange: (event: ChangeEvent) => void, ) { await initSheetwrite(); const grid = createGrid(host, { workbook, data }); const off = grid.on("change", persistChange); return () => { off(); grid.destroy(); }; } ``` A datasource-backed grid may use dense storage or allocation-lazy paged storage. Full-sheet operations can throw `IncompleteDataError` until every required page is loaded. Worker rendering automatically falls back to the main-thread canvas when capabilities or startup fail; observe `renderer-fallback` if the host needs to report that transition. - [Run the Vanilla example](https://sheetwrite.vercel.app/vanilla/) - [Installation and explicit WASM sources](https://sheetwrite.vercel.app/docs/start/installation/) - [Runtime ownership](https://sheetwrite.vercel.app/docs/concepts/runtime-ownership/) - [`@sheetwrite/core` API](https://sheetwrite.vercel.app/docs/api/core/) ### Vue Source: https://sheetwrite.vercel.app/docs/frameworks/vue/ `@sheetwrite/vue` initializes WASM after client mount. The simple component accepts `columns` and `defaultRows`; Vue templates use kebab-case event listeners such as `@grid-change` and `@ready`. The component exposes `grid` before emitting `ready`. A reset clears the old exposed handle, creates a new generation, and then publishes the replacement. Reattach any coordinator that subscribed to the old grid. ```vue ``` Use a client-only boundary in Nuxt or another server-rendered host; importing or rendering the component on the server does not start WASM. - [Run the Vue datasource example](https://sheetwrite.vercel.app/vue/) - [Lifecycle and reset semantics](https://sheetwrite.vercel.app/docs/frameworks/lifecycle/) - [`@sheetwrite/vue` API](https://sheetwrite.vercel.app/docs/api/vue/) ### Accessibility Source: https://sheetwrite.vercel.app/docs/guides/accessibility/ [Installation](https://sheetwrite.vercel.app/docs/start/installation/) The grid paints to a ``, which assistive technology can not read. To make it navigable, Sheetwrite maintains a parallel **ARIA shadow tree**: a visually-hidden DOM mirror of the visible window that carries the grid semantics, while the canvas itself is hidden from the accessibility tree. ## The host grid `createGrid` annotates the host element as the grid: | Attribute | Value | | --- | --- | | `role` | `"grid"` | | `aria-multiselectable` | `"true"` | | `aria-rowcount` | total rows **+ 1** (the column-letter header counts as a row) | | `aria-colcount` | number of visible columns | | `aria-readonly` | `"true"` when the grid is `readOnly` (omitted otherwise) | | `aria-label` | `"Spreadsheet grid"` (default; not overwritten if you set your own) | | `aria-activedescendant` | the id of the focused cell in the mirror | The visual layers — the scroller, the overlay, and the `` — are all marked `aria-hidden="true"`, so screen readers ignore the pixels and read the mirror. ## The SR-only mirror Alongside the host the grid appends a visually-hidden `
` with a unique id (`sheetwrite-grid-`) and `role="rowgroup"`. It contains: - A **header row** — `role="row"`, `aria-rowindex="1"` — whose cells are `role="columnheader"` with a 1-based `aria-colindex`. Spreadsheet presentation uses the positional column letter (`A`, `B`, …); data-grid presentation uses the semantic `Column.header`. - One **data row** per visible row — `role="row"` with `aria-rowindex` set to the true row position (data row + 2, leaving index 1 for the header). Its cells are `role="gridcell"` with a 1-based `aria-colindex`, a stable id (`--`), and the cell's value as text. - Every visible cell covered by the current cell/range/multi selection carries `aria-selected="true"`. A row selection marks its visible row; a column selection marks both its header and visible cells. The focused cell's id is mirrored into the host's `aria-activedescendant`. Sketch of the emitted structure: ```html
A
B
Widget
12
``` As keyboard or pointer input changes the selection, `aria-selected` and the host's `aria-activedescendant` update. Scrolling rebuilds the visible mirror. Horizontal virtualization does not renumber the mirror: if the visible window starts at sheet column G, its header and cells use `aria-colindex="7"`. Frozen and scrolling columns likewise keep their absolute one-based sheet positions. A focused cell note is connected through `aria-describedby` to hidden plain text. Validation list and checkbox editors use real `listbox` / `option` and `checkbox` semantics, with the rule help text as their accessible label. Host-supplied editors receive a `context.label` composed from that same semantic/positional header and row number. Core applies it to the retained editor wrapper and to the first focusable control when the editor did not provide its own name. Commit, cancel, reset, and unmount return focus to the grid; teardown aborts the editor signal before removing its DOM. Custom DOM renderer output uses the same mirror for its default cell value. Non-semantic renderer elements are hidden from assistive technology to avoid a duplicate announcement. A renderer can expose an explicitly interactive, labelled control; its keyboard events remain with that control, its element is retained across scroll and geometry updates, and focus returns to the grid if virtualization or renderer replacement removes it. ## Limitation: the mirror reflects the visible window The mirror contains **only the rows and columns currently rendered** (the same virtualized window the canvas paints, plus overscan) — not the entire sheet. This is deliberate: materializing 100k+ rows of DOM would defeat the point of canvas rendering. The consequences to be aware of: - `aria-rowindex` / `aria-colindex` carry the **true** positions, so assistive technology can announce "row N of `aria-rowcount`" correctly even though only a window is present. - A screen reader's own virtual cursor can only walk the cells in the rendered window. Use the grid's keyboard navigation (arrows, Page Up/Down, Home/End — see [Interaction](https://sheetwrite.vercel.app/docs/guides/interaction/#keyboard-navigation)) to move focus; that scrolls new rows into view and into the mirror, and updates `aria-activedescendant`. ### Collaboration and offline work Source: https://sheetwrite.vercel.app/docs/guides/collaboration/ Sheetwrite provides transport-, database-, and authentication-neutral collaboration primitives. The host still owns server sequencing, durable document storage, user identity, authorization, and network lifecycle. ## Load, mount, queue, and acknowledge Load a validated snapshot before mounting, then let one `SyncCoordinator` observe local grid transactions. Do not also save `event.changes`: that list is cell-oriented rollback detail and omits document metadata operations. ```ts import { createGridFromSnapshot, SyncCoordinator, type PersistenceAdapter, type RemoteOperationSource, } from "@sheetwrite/core"; const documentId = "workbook-42"; const snapshot = await persistenceAdapter.load(documentId); const grid = createGridFromSnapshot(document.querySelector("#grid")!, snapshot); const sync = new SyncCoordinator(grid, persistenceAdapter, { documentId, serverVersion: snapshot.version ?? 0, }); sync.on((event) => { if (event.type === "acknowledged") { console.log(`server version ${event.version} acknowledged ${event.clientMutationId}`); } else if (event.type === "conflict") { console.error("conflict retained for host recovery", event.mutation); } }); await sync.ready(); const unsubscribeRemote = sync.subscribe(remoteOperationSource); saveButton.addEventListener("click", () => { void sync.flush(); // sends in order; each response acknowledges only its mutation ID }); // Teardown: // unsubscribeRemote(); // sync.destroy(); // grid.destroy(); ``` `SyncCoordinator` subscribes to `Grid` changes itself. Local rendering remains optimistic; the host controls when to call `sendNext`, `flush`, or `retry`. `applied` and `duplicate` responses acknowledge the matching operations and remove that mutation. A transport error leaves the immutable record pending. An HTTP adapter can stay transport-neutral at the core boundary: ```ts import type { PersistenceAdapter, PersistenceCommitRequest, PersistenceCommitResponse, WorkbookSnapshot, } from "@sheetwrite/core"; async function responseJson(response: Response): Promise { if (!response.ok) throw new Error(`Persistence request failed: ${response.status}`); return (await response.json()) as T; } const persistenceAdapter: PersistenceAdapter = { async load(documentId, signal) { const response = await fetch(`/api/documents/${encodeURIComponent(documentId)}`, { signal }); return responseJson(response); }, async commit(request: PersistenceCommitRequest) { const { signal, ...body } = request; const response = await fetch( `/api/documents/${encodeURIComponent(request.documentId)}/operations`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal, }, ); return responseJson(response); }, }; ``` The server must authenticate the request, authorize every operation, validate the document schema/policy, and assign the version. Client protection metadata is not authorization. ## Database-neutral server model A snapshot plus append-only operation log is the default persistence shape: ```sql documents( document_id primary key, current_version integer not null, snapshot_version integer not null, snapshot_json json not null ) document_operations( document_id not null, version integer not null, client_mutation_id text not null, operations_json json not null, created_at server_timestamp not null, primary key(document_id, version), unique(document_id, client_mutation_id) ) ``` Handle `commit()` in one database transaction: 1. Return `duplicate` with the existing version when `(document_id, client_mutation_id)` already exists. 2. Lock/read the document version. If it differs from `baseVersion`, return `conflict` with ordered operations since base or a current snapshot. 3. Validate and authorize the submitted `DocumentOp[]` on the server. 4. Append at `current_version + 1`, update the document version, and return `applied` with the same mutation ID. 5. Periodically fold the log into `snapshot_json`; never rewrite operation versions or accept client-authored server timestamps. Normalized cell tables are an alternative for products that need SQL queries over cell values. They do not replace the protocol: the host must still reconstruct a complete schema-versioned `WorkbookSnapshot`, preserve formula source/style/metadata, and serialize structural operations under one document version. Mixing independent cell writes with the operation log breaks atomic row/column/formula semantics. ## Durable pending commits `SyncCoordinator` can receive a `PendingCommitStorage` adapter. With one configured, it: 1. persists each immutable commit before it can be sent; 2. restores pending commits on startup and reapplies them locally as remote-source operations, so restoration does not echo into the outgoing queue; 3. retries with the original `clientMutationId` after reconnect or reload; 4. removes durable work only after an `applied` or `duplicate` acknowledgement; and 5. retains conflicts and storage failures for explicit host recovery. Await `coordinator.ready()` before declaring startup synchronized. Use `initialConnection: "offline"` for offline-first startup, `setOnline(false)` on disconnect, and `setOnline(true)` to reconnect and drain pending work in order. `coordinator.state` distinguishes connection state from hydration, persistence, sending, conflict, and error activity. The browser IndexedDB implementation is intentionally isolated from Node and SSR entrypoints: ```ts import { SyncCoordinator } from "@sheetwrite/core"; import { IndexedDbPendingCommitStorage } from "@sheetwrite/core/browser"; const pendingStorage = new IndexedDbPendingCommitStorage({ databaseName: "my-product-sheetwrite", }); const sync = new SyncCoordinator(grid, persistenceAdapter, { documentId: "workbook-42", serverVersion: snapshot.version ?? 0, pendingStorage, initialConnection: navigator.onLine ? "online" : "offline", }); await sync.ready(); window.addEventListener("online", () => sync.setOnline(true)); window.addEventListener("offline", () => sync.setOnline(false)); ``` IndexedDB record migration is versioned. Unsupported future schemas, blocked upgrades, quota exhaustion, transaction failures, and aborts surface as typed `IndexedDbPendingCommitStorageError` codes. Core continues to work without IndexedDB when no durable adapter is supplied. ## Remote operations and version gaps `SyncCoordinator.subscribe` accepts either `RemoteOperationSource` or `AsyncIterable`. Operations apply only at `serverVersion + 1`; own echoed mutation IDs and already acknowledged IDs are deduplicated. Remote application uses `Grid.applyRemoteOperations`, so it does not create outgoing dirty work or local undo entries. A gap emits `reload-required`. Prefer fetching missing ordered operations through `recoverVersionGap`; the coordinator applies them in sequence. A returned snapshot is a host remount signal, not an automatic store replacement. `resumeAfterReload(snapshot)` updates retained queue base versions only after the host has installed that snapshot and reapplied local work; it does not hydrate a grid or attach the coordinator to a newly created grid. ### Conflict and snapshot reload Never acknowledge or discard a conflicted mutation implicitly. If the server returns both `operationsSinceBase` and a current snapshot, the host can gate every pending transaction through the conservative rebaser, clear the old durable IDs, remount, and submit the safe results as new mutations: ```ts import { createGridFromSnapshot, rebaseDocumentOperations, SyncCoordinator, type Grid, type PersistenceCommitResponse, } from "@sheetwrite/core"; type ConflictResponse = Extract; interface SyncSession { grid: Grid; sync: SyncCoordinator; unsubscribeRemote: () => void; } async function reloadAfterConflict( session: SyncSession, response: ConflictResponse, ): Promise { if (!response.operationsSinceBase) { console.error("Manual conflict review required: server did not return an operation tail"); return session; } const pending = session.sync.pendingCommits(); const remoteOperations = response.operationsSinceBase.flatMap((entry) => [ ...entry.operations, ]); const safeBatches: Array & { status: "rebased"; }> = []; for (const record of pending) { const result = rebaseDocumentOperations(record.operations, remoteOperations); if (result.status === "conflict") { console.error("Manual conflict review required", result.conflict); return session; // old queue and grid remain intact } safeBatches.push(result); } const latest = response.snapshot ?? (await persistenceAdapter.load(documentId)); for (const record of pending) { await pendingStorage.remove(documentId, record.clientMutationId); } session.unsubscribeRemote(); session.sync.destroy(); session.grid.destroy(); const grid = createGridFromSnapshot(document.querySelector("#grid")!, latest); const sync = new SyncCoordinator(grid, persistenceAdapter, { documentId, serverVersion: latest.version ?? 0, pendingStorage, }); await sync.ready(); // the stale IDs were removed before hydration for (const batch of safeBatches) { const outcome = grid.applyTransaction({ patches: [...batch.operations] }); if (outcome.status !== "applied") { throw new Error(`Rebased local transaction was not applied: ${outcome.status}`); } } const unsubscribeRemote = sync.subscribe(remoteOperationSource); await sync.flush(); return { grid, sync, unsubscribeRemote }; } ``` When the rebaser reports `conflict`—overlap, formulas across structural changes, concurrent moves, or sheet lifecycle ambiguity—keep the original queue and show product-specific conflict UI. If the server returns only a snapshot, the host cannot infer a safe transform; offer explicit discard/export/manual re-entry instead. Reloading a snapshot over pending edits without this decision loses intent. ## Presence `PresenceCoordinator` publishes actor metadata, active sheet, and bounded selection ranges through a host `PresenceTransport`. Presence: - is never written to document operations, snapshots, dirty state, or history; - expires according to local heartbeat receipt time; - is bounded by actor and range limits; - clamps ranges to valid visible grid geometry and hides other-sheet or filtered selections; - reuses the grid overlay element pool rather than mutating canvas cells; and - supports privacy controls for display name, selections, inbound presence, and actor filtering. Presence is advisory UI state. Do not use it for authorization, locking, or conflict prevention. ## Revisions `RevisionCoordinator` delegates revision listing, loading, and restore to a `RevisionAdapter`. Preview snapshots pass through the optional host schema migrator and mount with `readOnly: true`. Restore requests include `targetVersion`, current `baseVersion`, and a stable mutation ID. The adapter contract requires restore to create a newer auditable server version; it must never rewind database state in place. ## Comments Comments are document-adjacent server entities, not fields on every cell. `CommentCoordinator` stores stable thread/message IDs and range or cell anchors, while adapters supply authenticated author references and server timestamps. Create, reply, and resolve requests contain no client-authored identity or time fields. Authorization remains entirely host/server-owned. Comment streams are versioned independently. Out-of-order events emit a gap rather than being applied speculatively. ## Concurrent structural editing decision Sheetwrite uses deterministic server ordering plus conservative rebase, not a CRDT or general OT layer. `rebaseDocumentOperations` safely shifts non-overlapping literal edits and references across server-ordered row/column insertions and deletions. It reports explicit conflicts for: - edits or range pastes overlapping inserted/deleted boundaries; - targets or references deleted by a concurrent operation; - formula source crossing structural edits, because text-only rewriting cannot preserve sheet-aware reference intent; - concurrent row/column moves; - conflicting sheet lifecycle operations; and - metadata families that need domain-specific transforms. This choice preserves stable sheet IDs and authoritative formula source without lossy adapters. A CRDT/OT dependency is not justified by current scenarios: short-lived offline and online collaboration converge under server sequencing, while long-lived offline concurrent structural edits require product-level conflict UX and spreadsheet-specific formula/range semantics that generic sequence CRDTs do not supply. Revisit only if requirements demand automatic preservation of those ambiguous edits and scenario tests define the intended result for every structural/formula case. ### Configuration Source: https://sheetwrite.vercel.app/docs/guides/configuration/ [Installation](https://sheetwrite.vercel.app/docs/start/installation/) Everything you pass to `createGrid(host, opts)` lives in `GridOptions`. This page lists every field with its type and default, the optional toolbar `GridConfig`, and the `Grid` instance API. ## GridOptions ```ts type CreateGrid = (host: HTMLElement, opts: GridOptions) => Grid; ``` | Field | Type | Default | Notes | | --- | --- | --- | --- | | `workbook` | `Workbook` | — (required) | Sheets, columns, row counts, and the `activeSheet` id. | | `presentation` | `"spreadsheet" \| "data-grid"` | `"spreadsheet"` | Positional A/B/C headers or semantic `Column.header` labels. Addressing and data rows are unchanged. | | `data` | `ColumnarData` | `undefined` | Eager, in-memory, column-major values. Pass this **or** `datasource`. | | `datasource` | `DataSource` | `undefined` | Lazy, paged async source; rows fetched per visible window. | | `datasourceStorage` | `DataSourceStorageOptions` | `{ mode: "dense" }` | Use `{ mode: "paged", chunkRows?, cacheBytes? }` for allocation-lazy datasource storage. | | `renderer` | `"canvas" \| "worker"` | `"canvas"` | `"worker"` paints off-thread via OffscreenCanvas, falling back to canvas. See [Worker rendering](https://sheetwrite.vercel.app/docs/guides/worker-rendering/). | | `workerUrl` | `string \| URL` | `undefined` | Browser-fetchable module URL, e.g. `"/sheetwrite/worker.js"` after copying the package `dist/`; Vite can import `@sheetwrite/core/worker?worker&url`. See [Worker rendering](https://sheetwrite.vercel.app/docs/guides/worker-rendering/). | | `theme` | `Partial` | `undefined` | Overrides merged over `DEFAULT_THEME` and any `--sheetwrite-*` CSS vars. See [Styling](https://sheetwrite.vercel.app/docs/guides/styling/). | | `readOnly` | `boolean` | `false` | When `true`, all mutating interactions (edit, clear, fill, paste, restyle) are disabled and the host gets `aria-readonly="true"`. | | `protectionResolver` | `ProtectionResolver` | `undefined` | Host callback for protected local mutations; no resolver means deny. Client-side UX policy only, never server authorization. | | `mutationPolicy` | `"atomic" \| "partial"` | `"atomic"` | Reject the whole local transaction on a denied protected operation, or apply allowed operations and report denied ones. | | `renderers` | `Record` | `{}` | Custom cell renderers registered up front; reference one by name via `Column.renderer`. Also see `defineCellRenderer`. | | `editors` | `Record` | `{}` | Host-owned cell editors registered up front; reference one by name via `Column.editor`. | | `overscan` | `number` | `6` | Row and visible-column positions painted on each viewport edge; `0` disables the buffer. | | `minColumns` | `number` | workbook width | Minimum rendered/store column count, including empty spreadsheet padding columns. | | `config` | `GridConfig` | `undefined` | Presence opts into the built-in toolbar (see below). Omit for no toolbar. | Framework adapters classify every `GridOptions` field centrally. `workbook`, `data`, `datasource`, `datasourceStorage`, `presentation`, `editors`, `protectionResolver`, `mutationPolicy`, and `transactionResourceLimits` create an `input-reset`; `renderer`, `workerUrl`, and `renderers` create a `renderer-reset`. `theme`, `readOnly`, `config`, `overscan`, and `minColumns` update the existing grid live. Readiness includes the resulting generation and reset reason. Framework adapters also accept `wasmSource`. Initialization is process-wide and first-source-wins: concurrent calls using the same source share one attempt, while a different source rejects until that attempt settles. If the winning attempt succeeds, every still-mounted adapter becomes ready even when its prop changed during the attempt. Changing `wasmSource` after readiness warns and keeps the live grid, selection, edits, generation, and ready-event count unchanged. A true initialization failure remains observable through `onInitializationError` and a later source can retry it. ## Spreadsheet and data-grid presentation `presentation: "spreadsheet"` is the backward-compatible default. It paints and announces positional column headers (`A`, `B`, `C`, …). Use `presentation: "data-grid"` when the columns describe row-object fields: ```ts const workbook: Workbook = { activeSheet: "people", sheets: [{ id: "people", name: "People", rowCount: people.length, columns: [ { key: "name", header: "Customer name", width: 220, type: "text" }, { key: "status", header: "Account status", width: 160, type: "text" }, ], }], }; const grid = createGrid(host, { workbook, data, presentation: "data-grid", }); ``` The semantic header occupies the same header band as `A/B/C`; it does **not** consume row 0. Cell addresses, formulas, selections, row numbers, clipboard payloads, CSV/XLSX schema labels, mutation events, and datasource ranges keep their existing zero-based data coordinates. Table exports use `Column.header` in both presentation modes. The data-first `Sheetwrite` components select `"data-grid"` automatically because every `SimpleColumn` requires a `title`. Presentation is construction-bound. Changing it through a framework adapter replaces the grid with `reason: "input-reset"` rather than renaming columns or moving data in place. ## Host-supplied editors Set `Column.editor` to a key in `GridOptions.editors`. A custom editor takes precedence over the built-in validation-list editor. If the key is absent, Sheetwrite falls back to validation editing or its stock text/date editor. ```ts const statusEditor: CellEditor = { mount(host, context) { const select = document.createElement("select"); select.setAttribute("aria-label", context.label); for (const value of ["Prospect", "Active", "Paused"]) { select.add(new Option(value, value)); } select.value = context.text; host.appendChild(select); select.focus(); return { update(next) { select.setAttribute("aria-label", next.label); select.value = next.text; }, reposition(rect) { select.style.width = `${rect.width}px`; select.style.height = `${rect.height}px`; }, commit() { return select.value; }, cancel() {}, destroy() { select.remove(); }, }; }, }; const grid = createGrid(host, { workbook, data, presentation: "data-grid", editors: { status: statusEditor }, }); ``` The returned value is parsed with the column's normal type and committed through the same document transaction as stock editing. That preserves protection, validation, mutation policy, undo/redo, change events, and edit-commit events. An asynchronous commit remains bound to the data row captured at `mount`, even when sorting or `clearView()` moves that row before the promise settles. If a filter removes the row from the view, Sheetwrite cancels and aborts the pending edit instead of retargeting another row. Returning a rejected promise, `undefined`, or a non-string value from untyped JavaScript cancels without a mutation. Enter commits and moves down, Tab/Shift+Tab commit and move horizontally, and Escape cancels. Focus returns to the grid after commit or cancel. For remote choices, use the editor-owned `AbortSignal`; never let a late request write into a destroyed editor: ```ts const assigneeAutocomplete: CellEditor = { mount(host, context) { const input = document.createElement("input"); const list = document.createElement("datalist"); list.id = `assignees-${context.address.row}-${context.address.col}`; input.setAttribute("list", list.id); input.setAttribute("role", "combobox"); input.setAttribute("aria-label", context.label); input.value = context.initialInput ?? context.text; host.append(input, list); input.focus(); void fetch(`/api/people?q=${encodeURIComponent(input.value)}`, { signal: context.signal, }) .then((response) => response.json() as Promise>) .then((people) => { if (context.signal.aborted) return; list.replaceChildren( ...people.map((person) => { const option = document.createElement("option"); option.value = person.name; option.dataset.id = person.id; return option; }), ); }) .catch((error: unknown) => { if (!(error instanceof DOMException && error.name === "AbortError")) throw error; }); return { update(next) { input.setAttribute("aria-label", next.label); }, reposition(rect) { input.style.width = `${rect.width}px`; }, async commit() { await Promise.resolve(); return input.value; }, cancel() {}, destroy() { input.remove(); list.remove(); }, }; }, }; ``` One retained wrapper and one `CellEditorInstance` exist per active edit: | Hook / value | Guarantee | | --- | --- | | `mount(host, context)` | Runs once. The host is positioned over the active cell. | | `context.address` / `viewAddress` | Canonical data-row and current displayed-row snapshots. They are readonly and mutating a JavaScript object received by the editor cannot retarget the edit. | | `context.value` / `text` / `initialInput` | Resolved scalar, formatted text, and optional typed character. | | `context.label` | Accessible name derived from semantic/positional header plus row number. | | `context.signal` | Aborted before the editor's `cancel()` or `destroy()` hook on cancel, commit, reset, or unmount. | | `context.commit()` / `cancel()` | Optional editor-driven completion using the canonical path. A non-string commit from untyped JavaScript cancels safely. | | `update(context)` | External value, theme, zoom, view permutation, or geometry-sensitive state changed without replacing ownership. | | `reposition(rect)` | The active cell moved or resized. | | `commit()` | May return input synchronously or asynchronously; duplicate completion is ignored. | | `cancel()` | Notification before a host-requested cancellation; the signal is already aborted. | | `destroy()` | Runs exactly once; late async work must observe the aborted signal. A thrown hook error is reported without interrupting Sheetwrite's DOM, listener, or store cleanup. | `editors` is construction-bound so React/Vue/Svelte resets safely abort and destroy an active editor before publishing the new grid generation. All adapters accept the same registry: ```tsx setCommandStates(states)} /> ``` ```vue ``` ```svelte commandStates = event.states} /> ``` ### Datasource pages The cancellable request API owns one visible-window generation. Pages can carry authoritative formula source and style rather than only resolved scalars: ```ts import type { DataSource } from "@sheetwrite/core"; interface ApiRow { label: string; quantity: number; price: number; } const datasource: DataSource = { async getRows({ sheet, start, end, signal, revision }) { const response = await fetch( `/sheets/${encodeURIComponent(sheet)}?start=${start}&end=${end}`, { signal }, ); if (!response.ok) throw new Error(`Datasource request failed: ${response.status}`); const records = (await response.json()) as ApiRow[]; return { start, revision: response.headers.get("etag") ?? revision, rows: records.map((record, index) => ({ label: record.label, quantity: record.quantity, price: record.price, total: { value: { kind: "formula", src: `=B${start + index + 1}*C${start + index + 1}`, }, style: { numberFormat: "$#,##0.00", bold: true }, }, })), }; }, }; ``` `rows` may contain scalars, `CellValue` objects, or `{ value: CellValue, style?: CellStyle }` wrappers. Hydrated formulas, references, and styles do not emit user change events. Short pages mark only the returned rows loaded; malformed ranges emit `datasource-error` and remain retryable. Resetting or destroying the grid aborts outstanding requests, and a late page never overwrites a cell edited after that request began. Dense storage is the default. `{ mode: "paged" }` allocates power-of-two row chunks only for loaded or locally edited areas; `cacheBytes` bounds clean cached chunks, while dirty chunks remain pinned until acknowledgement. Full-sheet queries and exports report incomplete data until all required pages are loaded. `Store.queryCapability(sheet)` and `getCellLoadState(addr)` expose that state. Default chunk/cache values and eviction behavior are listed in [Compatibility and limits](https://sheetwrite.vercel.app/docs/reference/compatibility-limits/#rendering-interaction-and-paged-data). ### Serializable documents Use `WorkbookSnapshot` plus `validateWorkbookSnapshot()` at persistence boundaries. `schemaVersion: 1` rejects unsupported future schemas with structured errors. `DocumentOp` is the exhaustive reducer operation union, including metadata and sheet lifecycle operations. Session-only grid options such as `renderer`, `readOnly`, local zoom, selection, scroll, search, and temporary highlights never belong in a snapshot. `createGridFromSnapshot(host, snapshot, options)` validates and hydrates every sheet before mounting. Hydration emits no change event or undo entry. `grid.exportSnapshot()` uses bulk sheet reads and returns deterministic sparse blocks. `grid.applyRemoteOperations(operations)` emits a change with `source: "remote"` while remaining outside local undo history and `SyncCoordinator`'s outgoing queue. `SyncCoordinator` queues each non-empty local transaction as an immutable mutation record. Its `serverVersion` option is required and should come from the loaded snapshot. `sendNext()` and `retry(id)` are host-controlled; retries retain the original ID. `subscribe(source)` accepts a transport-neutral callback source and validates strict version order. Use `MemoryPersistenceAdapter` as an executable, server-sequenced reference—not as durable storage. Adapter methods accept `AbortSignal`; transport failures use `PersistenceError`, while version conflicts are typed commit responses that retain local work. A `CellRenderer` paints or retains a DOM node for each cell in the rendered window. When `dom` is present, it owns the cell content (the canvas still paints the cell background, border, headers, and grid lines): ```ts interface CellRenderer { canvas?(ctx: CanvasRenderingContext2D, c: CellPaintContext): void; dom?(c: CellPaintContext): HTMLElement; update?(element: HTMLElement, c: CellPaintContext): void; destroy?(element: HTMLElement): void; } ``` `dom` creates an element when a cell enters the bounded rendered window. `update` receives that same element after values, styles, theme, zoom, size, or scroll geometry change. `destroy` runs immediately before the element leaves the window, is replaced by a newly registered renderer, or its grid is reset or destroyed. Implement `update` to preserve focus and element-local state. A legacy renderer with only `dom` is recreated when its value, style, theme, or size changes, but not for a pure scroll. DOM cells are clipped to the viewport and frozen pane that owns them. A merged range produces one node for its anchor, not one node per covered cell. Plain renderer output stays hidden from assistive technology because the compact ARIA mirror already exposes its cell value. To make a renderer explicitly interactive, return a native control (or add a non-negative `tabindex`), give it an accessible name, and set `element.style.pointerEvents = "auto"`. Keyboard events from that control stay with the control instead of moving the grid. With `renderer: "worker"`, `canvas` hooks cannot cross the Worker boundary. `dom`, `update`, and `destroy` still run on the main thread in the retained overlay. ## GridConfig (toolbar) Set `config` to show the built-in toolbar. Each flag toggles one control; all flags **default to `true`** when `config` is present. The one special case is `toolbar: false`, which suppresses the toolbar entirely. ```ts createGrid(host, { workbook, config: {} }); // toolbar with every control createGrid(host, { workbook, config: { sort: false } }); // toolbar, no sort control createGrid(host, { workbook, config: { toolbar: false } }); // no toolbar createGrid(host, { workbook }); // no toolbar (config omitted) ``` | Flag | Type | Default | Control | | --- | --- | --- | --- | | `toolbar` | `boolean` | `true`¹ | Master switch. `false` removes the toolbar. | | `bold` | `boolean` | `true` | Bold toggle. | | `italic` | `boolean` | `true` | Italic toggle. | | `align` | `boolean` | `true` | Left / center / right alignment. | | `textColor` | `boolean` | `true` | Text color picker. | | `fillColor` | `boolean` | `true` | Fill (background) color picker. | | `border` | `boolean` | `true` | Border control. | | `clearFormat` | `boolean` | `true` | Clear formatting. | | `merge` | `boolean` | `true` | Merge / unmerge selection. | | `sort` | `boolean` | `true` | Sort the selected column. | | `export` | `boolean` | `true` | CSV / XLSX export buttons. | | `undo` | `boolean` | `true` | Undo / redo buttons (also bound to Ctrl+Z / Ctrl+Shift+Z). | ¹ "Default `true`" means: when you supply a `config` object at all. With no `config` there is no toolbar. The export flag always enables CSV. Its XLSX button requires an explicit optional installation and `import "@sheetwrite/xlsx/register"` before use; the framework packages do not install an XLSX backend. Direct `grid.exportXlsx(...)` calls reject on failure; built-in toolbar and context-menu actions report the same failure through one `export-error` event. Every control acts on the current selection — see [Interaction](https://sheetwrite.vercel.app/docs/guides/interaction/). ### Host-owned command state Host chrome can use `grid.actions` without duplicating selection/history logic. Query one command with `grid.getCommandState(name)` or subscribe to the complete snapshot through `command-state-change` (`onCommandStateChange` in React/Svelte, `@command-state-change` in Vue): ```ts const update = (bold: GridCommandState, undo: GridCommandState) => { undoButton.disabled = undo.disabled; boldButton.disabled = bold.disabled; boldButton.setAttribute( "aria-pressed", bold.activity === "mixed" ? "mixed" : String(bold.activity === "active"), ); }; update(grid.getCommandState("bold"), grid.getCommandState("undo")); const stop = grid.on("command-state-change", ({ states }) => { update(states.bold, states.undo); }); boldButton.addEventListener("click", () => grid.actions.toggleBold()); undoButton.addEventListener("click", () => grid.actions.undo()); // Run during host teardown. stop(); ``` `disabled` accounts for read-only mode, empty selections, and undo/redo history. Formatting commands report `activity: "inactive" | "active" | "mixed"` across the current selection. The built-in toolbar uses the same contract, including native `disabled` and `aria-pressed="mixed"`. Aggregation is bounded; very large selections conservatively report `mixed` rather than forcing an unbounded cell walk. ### Feature flags Three `GridConfig` fields enable behavior that lives outside the toolbar row: | Field | Type | Default | Effect | | --- | --- | --- | --- | | `find` | `boolean` | `true` | Built-in **Ctrl+F** search box, next/previous controls, and live match count. | | `contextMenu` | `boolean \| ContextMenuItems` | `true` | Built-in right-click menu, disabled menu, static readonly rows, or a context-aware row factory. | | `icons` | `Partial>` | `undefined` | Built-in toolbar icon overrides. Strings render as text; a DOM `Node` or `() => Node` supports SVG/HTML without `innerHTML`. | ### Context-menu items The default menu contains copy, cut, paste, clear contents, row insert/delete/hide/show/auto-fit, column insert/delete/hide/show/auto-fit, clear column filter, merge, and unmerge actions, with separators between groups. `exportCsv` and `exportXlsx` are also valid built-in actions in a custom list. `ContextMenuItems` is either a `readonly ContextMenuItem[]` or a factory evaluated for each `ContextMenuContext`. An item may set a stable `id`, built-in `action`, visible `label`, `shortcut` hint, and static or context-aware `visible`/`disabled` policy. The lean context carries only `cell`, `clientX`, and `clientY`. `onClick(grid, cell)` overrides `action`; hidden separators are normalized. ```ts const config = { contextMenu: (context) => [ { id: "copy", action: "copy", label: "Copy value", shortcut: "Ctrl+C" }, { action: "separator" }, { id: "inspect", label: "Inspect cell", visible: context.cell !== null, disabled: ({ cell }) => cell === null, onClick(grid, cell) { if (cell !== null) console.log(grid.store.getCell(cell)); }, }, { action: "exportCsv", label: "Download CSV" }, ], } satisfies GridConfig; ``` For fully host-owned UI, set `contextMenu: false`, listen for the DOM `contextmenu` event on the host, call `grid.getCellAtPoint(event.clientX, event.clientY)`, update selection, and invoke `grid.actions` from the host menu. Sheetwrite does not prescribe the host's event lifecycle: ```ts host.addEventListener("contextmenu", (event) => { event.preventDefault(); const cell = grid.getCellAtPoint(event.clientX, event.clientY); if (cell !== null) grid.setSelection({ kind: "cell", addr: cell }); host.dispatchEvent( new CustomEvent("sheetwrite:context-menu", { detail: { cell, x: event.clientX, y: event.clientY, actions: grid.actions }, }), ); }); ``` See [Interaction → Context menu](https://sheetwrite.vercel.app/docs/guides/interaction/#context-menu), [Styling](https://sheetwrite.vercel.app/docs/guides/styling/#widget-and-context-menu-styling), and the generated [`ContextMenuContext`](https://sheetwrite.vercel.app/docs/api/core/context-menu-context/), [`ContextMenuItems`](https://sheetwrite.vercel.app/docs/api/core/context-menu-items/), [`ContextMenuItem`](https://sheetwrite.vercel.app/docs/api/core/context-menu-item/), and [`Grid.getCellAtPoint`](https://sheetwrite.vercel.app/docs/api/core/grid/#getcellatpoint) contracts. Undo/redo and find are keyboard-driven and work without the toolbar: **Ctrl+Z** undoes the last edit, **Ctrl+Shift+Z** redoes it, and **Ctrl+F** opens the find widget (unless `find: false`). ## Grid instance `createGrid` returns an imperative handle: ```ts interface Grid { readonly store: Store; readonly actions: GridActions; setActiveSheet(id: SheetId): void; scrollToCell(addr: CellAddress): void; getSelection(): Selection | null; setSelection(sel: Selection | null): void; setTheme(theme: Partial): void; defineCellRenderer(name: string, renderer: CellRenderer): void; aggregate(col: number, op: AggregateOp): number; sortBy(col: number, ascending?: boolean): void; sortByMulti(keys: readonly SortKey[]): void; filterBy(col: number, needle: string): void; setColumnFilter(col: number, filter: ColumnFilter | null): void; getColumnFilters(): ReadonlyMap; distinctValues(col: number, limit?: number): CellScalar[]; hideRows(rows: readonly number[]): void; showRows(rows?: readonly number[]): void; hiddenRows(): readonly number[]; hideColumns(cols?: readonly number[]): void; showColumns(cols?: readonly number[]): void; hiddenColumns(): readonly number[]; groupRows(start: number, end: number): void; ungroupRows(start: number, end: number): void; setGroupCollapsed(start: number, collapsed: boolean): void; rowGroups(): readonly RowGroup[]; clearView(): void; undo(): void; redo(): void; exportCsv(filename: string): void; exportXlsx(filename: string): Promise; search(query: string, opts?: SearchOptions): SearchResult; findNext(): SearchResult; findPrev(): SearchResult; clearSearch(): void; replaceCurrent(replacement: string): SearchResult; replaceAll(replacement: string): ReplaceResult; insertRows(at: number, count?: number): void; removeRows(at: number, count?: number): void; insertColumns(at: number, count?: number): void; removeColumns(at: number, count?: number): void; highlightCells(ranges: readonly HighlightRange[] | null, color?: string): void; styleRange(range: Range, style: Partial | null): void; setValidationRule(rule: DataValidationRule): ApplyTransactionResult; removeValidationRule(id: string): ApplyTransactionResult; setProtectedRange(range: ProtectedRange): ApplyTransactionResult; removeProtectedRange(id: string): ApplyTransactionResult; setProtectionResolver(resolver?: ProtectionResolver, mode?: MutationPolicyMode): void; setNote(addr: CellAddress, text: string | null): ApplyTransactionResult; getNote(addr: CellAddress): string | null; beginEdit(row: number, col: number, initial?: string, selectAll?: boolean): void; dataEdge(row: number, col: number, dRow: number, dCol: number): number | null; setRowHeight(row: number, height: number): void; setColumnWidth(col: number, width: number): void; autoFitRows(range?: Range): void; autoFitColumns(cols?: readonly number[]): void; setFrozen(rows: number, cols?: number): void; setZoom(zoom: number): void; getZoom(): number; on(evt: E, fn: (e: GridEvents[E]) => void): () => void; refresh(): void; destroy(): void; } ``` | Method | Purpose | | --- | --- | | `store` | The underlying [`Store`](https://sheetwrite.vercel.app/docs/concepts/runtime-ownership/#the-store-contract) — apply transactions, read cells, subscribe. | | `actions` | Imperative action surface (`toggleBold()`, `merge()`, `undo()`, `exportCsv()`, …) the toolbar and context menu bind to — use it to wire custom controls. | | `setActiveSheet(id)` | Switch the visible sheet. | | `scrollToCell(addr)` | Scroll a cell into view. | | `getSelection()` / `setSelection(sel)` | Read or set the current [`Selection`](https://sheetwrite.vercel.app/docs/guides/interaction/#selection-model) (`null` clears it). | | `setTheme(partial)` | Merge a partial theme and repaint. | | `defineCellRenderer(name, r)` | Register a custom renderer after construction. | | `aggregate(col, op)` | Column aggregate; see [Data operations](https://sheetwrite.vercel.app/docs/guides/data-operations/#aggregate). | | `sortBy` / `sortByMulti` | Non-mutating display sorts; see [Data operations](https://sheetwrite.vercel.app/docs/guides/data-operations/#display-views). | | `filterBy` / `setColumnFilter` / `getColumnFilters` | Column filters that compose with sort, hidden rows, and row groups. | | `distinctValues(col, limit?)` | First-seen distinct values for building filter menus. | | `hideRows` / `showRows` / `hiddenRows` | Explicit row visibility separate from sort/filter state. | | `hideColumns` / `showColumns` / `hiddenColumns` | Bulk-safe persisted column visibility; omitted arguments target the focused column for hide and every column for show. | | `groupRows` / `ungroupRows` / `setGroupCollapsed` / `rowGroups` | Inclusive data-row groups with collapse state. | | `clearView()` | Clears sort/filter state; hidden rows and row groups remain. | | `setFrozen(rows, cols?)` | Pin leading view rows/columns while the body scrolls. | | `setZoom(z)` / `getZoom()` | Scale grid content between `0.5` and `2` without mutating workbook base sizes. | | `undo()` / `redo()` | Undo or redo the last recorded cell edit (also bound to Ctrl+Z / Ctrl+Shift+Z). | | `exportCsv` / `exportXlsx` | Download the active data; see [Data operations](https://sheetwrite.vercel.app/docs/guides/data-operations/#export). | | `search(query, opts?)` | Find matching cells; highlights them, emits `search`, returns a [`SearchResult`](#search). | | `findNext()` / `findPrev()` | Step the active match forward / backward and scroll it into view. | | `clearSearch()` | Drop the current search and clear its highlights. | | `replaceCurrent()` / `replaceAll()` | Replace literal text/number matches through undoable transactions. | | `insertRows` / `removeRows` / `insertColumns` / `removeColumns` | Structural edits through undoable patches. | | `highlightCells(ranges, color?)` | Highlight arbitrary ranges (`null` clears); `color` overrides the theme highlight. | | `styleRange(range, style)` | Merge or clear store-backed cell styles across a range. | | `setValidationRule` / `removeValidationRule` | Add, replace, or remove a serializable range validation rule. List and checkbox rules get accessible editors. | | `setProtectedRange` / `removeProtectedRange` / `setProtectionResolver` | Define protected-range metadata and host-owned local permission policy. This is not server authorization. | | `setNote` / `getNote` | Set, clear, or read a serializable plain-text cell note. | | `beginEdit(row, col, initial?, selectAll?)` | Open the inline editor at a view cell. | | `dataEdge(row, col, dRow, dCol)` | Ctrl+Arrow-style data-run jump target; vertical movement is view-aware under sort/filter. | | `setRowHeight(row, h)` / `setColumnWidth(col, w)` | Geometry APIs; row height is view-indexed and persists against the underlying data row. | | `autoFitRows(range?)` / `autoFitColumns(cols?)` | Explicit, undoable geometry fitting from bulk reads; auto-fit never runs during paint. | | `on(evt, fn)` | Subscribe to an event; returns an unsubscribe function. | | `refresh()` | Force a re-render (e.g. after mutating the workbook directly). | | `destroy()` | Tear down listeners, DOM, and ARIA attributes. | ## Search `grid.search(query, opts?)` scans cells, highlights every match, scrolls the first match into view, and emits a [`search`](#events) event. `findNext()` / `findPrev()` move the active match; `clearSearch()` clears the highlights. The built-in **Ctrl+F** find widget (gated by `config.find`, on by default) drives this same API. ```ts interface SearchOptions { matchCase?: boolean; // case-sensitive match (default false) wholeCell?: boolean; // match the whole cell, not a substring (default false) sheet?: SheetId; // restrict to one sheet (default: the active sheet) columns?: number[]; // restrict to these column indices (default: all) } interface SearchResult { query: string; matches: CellAddress[]; // matching cells, in row-major order active: number; // index of the active match, or -1 when there are none } ``` ```ts const result = grid.search("error"); console.log(`${result.matches.length} match(es)`); grid.findNext(); // advance the active match and scroll to it grid.clearSearch(); // remove the highlights when done ``` `highlightCells(ranges, color?)` highlights arbitrary ranges independently of search (pass `null` to clear); `color` overrides the theme highlight color. ## Events `grid.on(evt, fn)` returns an `off()` you should call to unsubscribe. | Event | Payload | | --- | --- | | `change` | `{ transaction; changes; dirty; commitReason; source: "local" \| "remote"; epoch? }` | | `selection` | `{ selection: Selection \| null }` | | `scroll` | `{ scrollTop: number; firstRow: number; lastRow: number }` | | `edit-begin` | `{ addr: CellAddress }` | | `edit-commit` | `{ addr: CellAddress; value: CellValue }` | | `search` | `SearchResult` — `{ query: string; matches: CellAddress[]; active: number }` | ```ts const sync = new SyncCoordinator(grid, adapter, { documentId: "products", serverVersion: loadedSnapshot.version ?? 0, }); const off = sync.on((event) => { if (event.type === "conflict") showConflict(event.response); if (event.type === "reload-required") requestFreshSnapshot(); }); saveButton.onclick = () => void sync.sendNext(); // later off(); sync.destroy(); ``` ### Data operations and export Source: https://sheetwrite.vercel.app/docs/guides/data-operations/ [Installation](https://sheetwrite.vercel.app/docs/start/installation/) Sheetwrite can sort, filter, hide, group, aggregate, import, and export the active sheet without rewriting the stored row data. Sorts and filters are display views: the store keeps a row-order permutation/subset and the renderer reads through it. ## Display views The simple shorthands are still available: ```ts grid.sortBy(4); // sort by column 4, ascending grid.sortBy(4, false); // descending grid.filterBy(3, "Tokyo"); // keep rows whose column-3 text contains "Tokyo" grid.clearView(); // clear sort/filter state only ``` For custom UI, use the composable primitives. All active column filters are ANDed together, hidden rows and collapsed groups are subtracted, and the remaining rows are sorted by the active multi-key sort. ```ts grid.sortByMulti([ { col: 4, ascending: false }, // primary key { col: 0, ascending: true }, // tie-breaker ]); grid.setColumnFilter(3, { kind: "contains", text: "Tokyo" }); grid.setColumnFilter(2, { kind: "values", values: ["Retail", "Partner"] }); grid.setColumnFilter(5, { kind: "compare", op: "gte", value: 1000 }); const cities = grid.distinctValues(3, 100); // data source for a filter menu grid.hideRows([1, 7, 9]); grid.groupRows(10, 25); grid.setGroupCollapsed(10, true); ``` | Method | Signature | Effect | | --- | --- | --- | | `sortBy` | `sortBy(col: number, ascending = true): void` | Reorder displayed rows by one column. | | `sortByMulti` | `sortByMulti(keys: readonly SortKey[]): void` | Stable multi-key sort; first key is primary. | | `filterBy` | `filterBy(col: number, needle: string): void` | Shorthand for a case-insensitive `contains` column filter. | | `setColumnFilter` | `setColumnFilter(col: number, filter: ColumnFilter \| null): void` | Set or clear one column filter. | | `getColumnFilters` | `getColumnFilters(): ReadonlyMap` | Current active column filters. | | `distinctValues` | `distinctValues(col: number, limit = 1000): CellScalar[]` | First-seen distinct resolved values for a column. | | `hideRows` / `showRows` | `hideRows(rows)` / `showRows(rows?)` | Hide/show data rows independent of sort/filter state. | | `hiddenRows` | `hiddenRows(): readonly number[]` | Currently hidden data rows. | | `groupRows` / `ungroupRows` | `groupRows(start, end)` / `ungroupRows(start, end)` | Create/remove an inclusive data-row group. | | `setGroupCollapsed` | `setGroupCollapsed(start, collapsed): void` | Collapse or expand the group starting at `start`. | | `rowGroups` | `rowGroups(): readonly RowGroup[]` | Current row-group definitions. | | `clearView` | `clearView(): void` | Clears sort/filter state. Hidden rows and row groups are preserved. | `clearView()` deliberately does **not** unhide rows or expand row groups. This matches spreadsheet behavior: clearing a filter removes the query, but explicit hidden rows and collapsed groups remain a separate visibility state. Use `showRows()` and `setGroupCollapsed(start, false)` to reverse those states. Because a view reorders or hides rows, two features that depend on a stable row-to-cell mapping are disabled while a view is active: - **Merged cells** are not drawn (the layout reports no merges under a view). - **Drag-to-fill** is unavailable (the fill handle is hidden). Call `clearView()` to remove sort/filter state; also show hidden rows / expand row groups if you need the full natural data order. ## Aggregate `aggregate` computes a column reduction over the active sheet's data and returns a number: ```ts type Aggregate = Grid["aggregate"]; ``` ```ts const total = grid.aggregate(4, "sum"); const rows = grid.aggregate(0, "count"); ``` ## Frozen panes and zoom Frozen panes and zoom are view geometry, not data mutations: ```ts grid.setFrozen(1, 1); // pin first view row and first column grid.setFrozen(0, 0); // unfreeze grid.setZoom(1.25); console.log(grid.getZoom()); ``` `setFrozen(rows, cols?)` pins leading view rows and columns while the body scrolls. `setZoom(z)` clamps to `0.5`-`2` and scales painted row/column geometry and fonts; workbook widths/heights remain in base units. ## Export The grid can download the active sheet directly. CSV is synchronous; XLSX is async because the optional backend produces bytes asynchronously. Install and register `@sheetwrite/xlsx` before enabling a framework toolbar export action or calling `grid.exportXlsx`: ```sh npm install @sheetwrite/xlsx ``` ```ts import "@sheetwrite/xlsx/register"; grid.exportCsv("sales.csv"); await grid.exportXlsx("sales.xlsx"); ``` `grid.exportXlsx(...)` rejects when registration or encoding fails, preserving the backend error. Built-in toolbar and context-menu actions cannot return that promise, so the grid emits one `export-error` event with `{ format: "xlsx", error }` instead: ```ts grid.on("export-error", ({ format, error }) => { console.error(`${format} export failed`, error); }); ``` CSV is written UTF-8 with a BOM and CRLF line endings, and string values are **injection-hardened** — a value starting with `=`, `+`, `-`, `@`, tab, or CR is prefixed with a single quote so it cannot become an executable formula when reopened in a spreadsheet app. Clipboard formulas and references retain their rich behavior only for a copy/cut pasted back through the same live Sheetwrite grid controller. Rich payloads from another controller or application, including spreadsheet HTML, paste as injection-neutralized text; `pasteValues` uses resolved literals. ### Standalone import/export functions Sheetwrite exposes two intentionally different XLSX contracts: - **Table interchange**: `toXlsxTable` / `fromXlsxTable`. This is the active-sheet, first-row-header API used by `grid.exportXlsx`. - **Workbook round-trip**: `toXlsxWorkbook` / `fromXlsxWorkbook`. This consumes and produces the same `WorkbookSnapshot` used by persistence, without adding a header row. Register the optional XLSX package once before using either contract: ```ts import { downloadBytes, fromCsv, fromXlsxTable, fromXlsxWorkbook, SheetwriteStore, toCsv, toTsv, toXlsxTable, toXlsxWorkbook, } from "@sheetwrite/core"; import "@sheetwrite/xlsx/register"; const dataFromCsv = fromCsv(csvText, columns); const tableBytes = await toXlsxTable(workbook, store); const dataFromTable = await fromXlsxTable(tableBytes); const workbookBytes = await toXlsxWorkbook(store.exportSnapshot(), { maxCells: 250_000, signal: abortController.signal, onWarning: (warning) => console.warn(warning.code, warning.message), }); const importedSnapshot = await fromXlsxWorkbook(workbookBytes); const importedStore = SheetwriteStore.fromSnapshot(importedSnapshot); const csv = toCsv(sheet, store); // string (BOM + CRLF, injection-hardened) const tsv = toTsv(range, store); // string (Excel/Sheets clipboard TSV) downloadBytes(csv, "sales.csv", "text/csv"); downloadBytes( workbookBytes, "workbook.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ); ``` | Function | Signature | | --- | --- | | `fromCsv` | `fromCsv(text: string, columns: readonly Column[]): ColumnarData` | | `fromXlsxTable` | `(data: ArrayBuffer \| Uint8Array, options?: XlsxWorkbookOptions) => Promise` | | `fromXlsxWorkbook` | `(data: ArrayBuffer \| Uint8Array, options?: XlsxWorkbookOptions) => Promise` | | `toCsv` | `toCsv(sheet: Sheet, store: Store): string` | | `toTsv` | `toTsv(range: Range, store: Store): string` | | `toXlsxTable` | `(workbook: Workbook, store: Store, options?: XlsxWorkbookOptions) => Promise` | | `toXlsxWorkbook` | `(snapshot: WorkbookSnapshot, options?: XlsxWorkbookOptions) => Promise` | | `downloadBytes` | `downloadBytes(bytes: Uint8Array \| string, filename: string, mime: string): void` | `XlsxWorkbookOptions` is the shared resource contract for every table and workbook path. `maxCells` bounds logical accepted cells and defaults to `1_000_000`; `resourceLimits` overrides the remaining input, archive, XML, dimension, and output budgets, which are checked before decompression and allocation and reject with a typed `XlsxResourceError`. `signal` is checked between bounded codec stages, and structured warnings surface through `onWarning`. Both directions materialize the complete file representation in memory; there is no streaming mode. ### XLSX compatibility | Feature | Table API | Workbook API | | --- | --- | --- | | Multiple worksheets and order | Active sheet only | Preserved | | First row | Synthesized column headers | Ordinary row; no header synthesis | | Formula source, including cross-sheet formulas | Exports resolved scalars | Preserved; workbook requests full recalculation on open | | Cell styles, borders, number/date formats | Preserved on active-sheet export | Preserved | | Merges, column widths, row heights | Preserved on active-sheet export | Preserved | | Frozen panes and active worksheet | Not round-tripped | Preserved | | Named ranges | Not represented | Preserved | | Sheetwrite conditional formats and row groups | Not represented | Preserved in Sheetwrite metadata; warning reports that no Excel rule/outline is emitted | | Boolean literals | Imported by the table reader | Preserved as native booleans | | Rich text and hyperlinks | Reader-dependent flattening | Display text preserved with a structured warning | | Sheetwrite validation rules | Not represented | Preserved in Sheetwrite metadata; supported list/numeric/date/text-length rules also emit native Excel validation | | Protected ranges | Not represented | Preserved in Sheetwrite metadata; no Excel sheet-protection claim is made | | Cell notes | Not represented | Preserved in Sheetwrite metadata and emitted as native Excel notes | | External Excel validation, protection, notes, images, tables, auto-filters, conditional formatting, and VBA | Not represented | Features without Sheetwrite metadata are dropped with a structured `unsupported-feature` warning when detected | Workbook formulas are imported as formula source, not as cached results. Hydrating the returned snapshot with `SheetwriteStore.fromSnapshot` recompiles them into the WASM formula engine. No raw formula handles enter the serialized snapshot. ### XLSX backends XLSX uses pluggable backends. Calling a table or workbook API without its backend throws a configuration error that names both remedies: install `@sheetwrite/xlsx`, then import `@sheetwrite/xlsx/register` before calling the function. The ordinary core entry never resolves the optional codec, so normal grid bundles carry no spreadsheet-file code. `@sheetwrite/xlsx` is the only concrete implementation package. Its root entry is side-effect free and exports `registerXlsxBackends()` plus the three named backend instances for explicit registration or wrapping. The `./register` entry performs idempotent registration: ```ts import { registerXlsxBackends, sheetwriteTableExportBackend, sheetwriteTableImportBackend, sheetwriteWorkbookBackend, } from "@sheetwrite/xlsx"; import { setXlsxTableExportBackend, setXlsxTableImportBackend, setXlsxWorkbookBackend, type XlsxTableExportBackend, type XlsxTableImportBackend, type XlsxWorkbookBackend, } from "@sheetwrite/core"; registerXlsxBackends(); // Or compose individual backends explicitly. setXlsxTableExportBackend(sheetwriteTableExportBackend); setXlsxTableImportBackend(sheetwriteTableImportBackend); setXlsxWorkbookBackend(sheetwriteWorkbookBackend); ``` The backends share Sheetwrite's internal bounded OOXML codec, which owns OPC part resolution, XML tokenization, styles, and worksheet mapping over one audited compression primitive (`fflate`). It preserves formula source, worksheets, styles, merges, dimensions, frozen views, and named ranges, and it enforces the shared resource limits before decompression. The host can replace any role by implementing `XlsxWorkbookBackend` or the table backend contracts. ## See also - [Interaction](https://sheetwrite.vercel.app/docs/guides/interaction/) for clipboard TSV and the toolbar sort control. - [Concepts](https://sheetwrite.vercel.app/docs/concepts/runtime-ownership/#document-protocol-and-storage-transactions) for row/column `DocumentOp` transactions. ### Formulas Source: https://sheetwrite.vercel.app/docs/guides/formulas/ [Installation](https://sheetwrite.vercel.app/docs/start/installation/) Sheetwrite evaluates formulas in the Rust/WASM calculation engine. Formula sources are persisted exactly as document values; evaluated results are cached for rendering, queries, export, and dependent formulas. Sheetwrite intentionally implements a coherent spreadsheet subset. It does **not** claim full Google Sheets or Excel formula parity. See the generated [formula function contract](https://sheetwrite.vercel.app/docs/reference/formula-functions/) for the complete inventory-derived function table and the [detailed compatibility results](https://sheetwrite.vercel.app/docs/reference/compatibility-results/) for checked operator, spill, preservation, and unsupported boundaries. ## Authoring formulas A formula is a `CellValue` with `kind: "formula"`. The leading `=` is optional at the storage API, although UI input conventionally includes it. ```ts grid.store.applyTransaction({ patches: [ { op: "set", addr: { sheet: "sheet1", row: 5, col: 4 }, value: { kind: "formula", src: "=SUM(E1:E5)" }, }, ], }); ``` `store.getFormula(address)` returns the source. `store.getCell(address).resolved` returns the evaluated scalar or error sentinel. An unsupported function evaluates to `#NAME?`, but its source remains retrievable and persists through snapshots/XLSX round-trips so a later engine can evaluate it. ## Supported formulas The checked, versioned `sheetwrite.formula-capabilities` contract is the source of truth for function registration. Its generated reference publishes all **100 required-supported target functions plus every incumbent function**, grouped by family, with aliases, exact signature and semantic profiles, implementation/evidence paths, source links, and behavior status. Do not infer support from an Excel, Google Sheets, or OpenFormula function with a similar name. [Browse the generated formula function contract →](https://sheetwrite.vercel.app/docs/reference/formula-functions/) Function names are case-insensitive. Commas are the only documented argument separator. Interior omitted optional arguments are preserved (`XLOOKUP(key, keys, results,, 0)`). Locale-specific separators are not accepted. ## Scalars, coercion, and errors Persisted literals and evaluated formula results use `string | number | boolean | null`. Boolean literals entered as `TRUE`/`FALSE`, formula comparisons, XLSX boolean cells, snapshots, visible windows, clipboard output, and CSV/TSV output remain booleans end to end. Display and text export use uppercase `TRUE` and `FALSE`. | Sentinel | Meaning | | --- | --- | | `#REF!` | Missing sheet/cell reference or invalid reference structure. | | `#VALUE!` | Wrong value type, malformed formula, invalid arity, or range-shape mismatch. | | `#DIV/0!` | Division/modulo by zero or an empty criteria average. | | `#NAME?` | Unknown function or unresolved named range. | | `#N/A` | Lookup did not find a compatible value. | | `#NUM!` | Non-finite numeric result, excessive recursion, or an oversized range. | | `#SPILL!` | A dynamic result intersects content, another spill, a merge, validation/protection metadata, or a sheet boundary. | | `#CALC!` | A supported array calculation has no result, such as `FILTER` without matches or an empty fallback. | | `#CYCLE!` | Direct or transitive formula/reference cycle. | | `#LOADING!` | A formula depends on datasource cells that have not loaded yet. | Errors are values for display and dependency propagation, not `NaN` or blank rendering. `IFERROR` can replace them. Unsupported or malformed source remains stored even when the result is an error. Coercion follows these documented rules: - Arithmetic converts numeric text and booleans (`TRUE = 1`, `FALSE = 0`); nonnumeric text returns `#VALUE!`. - Direct scalar boolean/text arguments may be coerced by numeric functions. Text/booleans reached through a range are ignored by numeric aggregates, matching common spreadsheet behavior. - Empty scalar arithmetic behaves as zero. Empty range cells are skipped. - Comparisons are case-insensitive for text and use spreadsheet type ordering. - `AND`, `OR`, `NOT`, and `IF` accept booleans, numbers, and the text `TRUE`/`FALSE`. ## LET bindings `LET(name1, value1, [name2, value2, …], calculation)` supports lexical, case-insensitive local names. A name starts with an ASCII letter or `_`; subsequent characters may also contain ASCII digits or `.`. Inner `LET` bindings shadow outer bindings, and each value expression can see only earlier bindings. Bindings are lazy. Only names reachable from `calculation` are expanded and evaluated, so an unused cell read, error, or `NOW()` does not become a dependency, error, or volatile marker. A used binding preserves ordinary formula dependency, spill, copy/fill, and structural-reference behavior. `LET(x,1/0,7)` therefore returns `7`, while `LET(x,A1,x+1)` tracks `A1`. One `LET` admits at most 126 bindings. Expansion admits at most 16,384 AST nodes across reachable bindings. Invalid name/arity/binding structure returns `#VALUE!`; expansion beyond the node ceiling returns `#NUM!`. `LET` does not create callable functions: `LAMBDA` and higher-order execution remain unsupported. ## References, ranges, and named ranges A1 cells (`A1`, `$B12`, `AA$3`), rectangular ranges (`A1:B3`), and cross-sheet references (`Sales!E2`, `'Sales 2026'!E2:E10`) are supported. Absolute markers affect fill/structural rewriting; they do not change evaluation. Named ranges are document operations and snapshot metadata: ```ts store.applyTransaction({ patches: [ { op: "setNamedRange", namedRange: { name: "Revenue", range: { sheet: "data", start: { row: 1, col: 4 }, end: { row: 100, col: 4 }, }, }, }, { op: "setNamedRange", namedRange: { name: "Revenue", scope: "summary", range: { sheet: "data", start: { row: 1, col: 5 }, end: { row: 100, col: 5 }, }, }, }, ], }); ``` A formula on `summary` resolves the sheet-scoped `Revenue`; formulas on other sheets resolve the workbook definition. Names are case-insensitive, must be formula-safe identifiers, and cannot look like A1 cells or boolean literals. Row/column insertions, removals, and moves rebase their rectangles. Deleting the complete target or its scope removes the definition; formulas then fall back to a workbook definition or evaluate to `#NAME?`. ## Operators | Precedence, high to low | Operators | Associativity | | --- | --- | --- | | Reference | `:` | — | | Unary sign | unary `+`, unary `-` | right | | Percentage | postfix `%` | left | | Exponentiation | `^` | left | | Multiplication | `*`, `/` | left | | Addition | `+`, `-` | left | | Concatenation | `&` | left | | Comparison | `=`, `<>`, `<`, `>`, `<=`, `>=` | one comparison | This deliberately follows Excel's spreadsheet precedence rather than programming-language conventions: `=-2^2` is `4`, `=2^3^2` is `64`, and `=2^-2` is `0.25`. Parenthesize formulas when portability to a non-spreadsheet evaluator matters. Percentage divides its operand by 100 and may repeat (`=50%%` is `0.005`). Concatenation formats numbers, booleans, and blanks as spreadsheet text; arithmetic and comparison bind before `&`. Comparisons return native booleans. For example, `=IF(A1 >= 100, A1 * 0.9, A1)`. ## Date/time semantics Dates are numbers: whole days since the spreadsheet epoch, with a fractional day for time. Sheetwrite uses only the Excel 1900 date system on this path: - serial `0` maps to `1899-12-31` - `DATE(1900,1,1) = 1` - serial `60` is retained as the compatibility-only, non-Gregorian date `1900-02-29` - `DATE(1900,3,1) = 61` - `DATE` treats 1900 as a leap year for this serial boundary and normalizes month/day overflow; `DATE(2024,13,1)` is 2025-01-01 - `DATEVALUE` is locale-neutral: it accepts the documented ISO `yyyy-mm-dd` form and fixed slash-delimited numeric forms; it never consults the host locale `TODAY` and `NOW` are volatile, but never consult the clock during paint or ordinary dependency reads. The host captures one absolute instant and triggers a barrier: ```ts store.recalculateVolatile(new Date("2026-07-13T18:00:00.000Z")); ``` The default captures `new Date()` once. The instant is converted to a UTC serial, so a fixed `Date` produces identical results in every host timezone. `TODAY` returns its whole-day component; `NOW` retains the fraction. Basic `TEXT` date patterns are `yyyy-mm-dd`, `yyyy/mm/dd`, `mm/dd/yyyy`, `dd/mm/yyyy`, `m/d/yyyy`, `yyyy-mm-dd hh:mm`, `yyyy-mm-dd hh:mm:ss`, `hh:mm`, and `hh:mm:ss`. Text evaluation is host-locale neutral. Function argument separators stay commas; `VALUE` uses `.` decimal and `,` grouping, and `NUMBERVALUE(text, [decimal_separator], [group_separator])` uses those same defaults unless the separators are supplied explicitly. Case conversion uses Unicode mappings, not browser locale. `LEN`, `LEFT`, `RIGHT`, and `MID` count Unicode scalar values rather than UTF-16 code units. Generated text is capped at 16 MiB and bounded searches at 4,000,000 steps; excess work returns `#NUM!`. ## Criteria semantics and shapes Criteria strings may begin with `=`, `<>`, `<`, `<=`, `>`, or `>=`. Numeric and boolean operands are parsed before text comparison. Plain text comparisons are case-insensitive. `*` matches zero or more characters, `?` matches one character, and `~` escapes the next wildcard. Wildcards apply to equality/inequality text criteria. Criteria are parsed once per formula evaluation, not once per cell. Shapes are exact row-by-column dimensions; equal cell counts with different dimensions do not match: - `COUNTIF(criteria_range, criterion)` accepts one matrix and one scalar criterion. - `COUNTIFS(criteria_range1, criterion1, …)` requires one or more range/criterion pairs. Every criteria range must have exactly the shape of the first. - `SUMIF(criteria_range, criterion, [value_range])` and `AVERAGEIF(...)` use `criteria_range` as the value range when the third argument is omitted. When supplied, `value_range` must have exactly the same shape; Sheetwrite does not implement Excel's top-left range extension. - `SUMIFS(value_range, criteria_range1, criterion1, …)`, `AVERAGEIFS`, `MAXIFS`, and `MINIFS` require one or more pairs after the value range. Every criteria range must exactly match the value range. A mismatch or malformed pair list returns `#VALUE!`. Matching result-range errors propagate. Text, booleans, and blanks in a numeric result range are ignored. A criteria sum with no numeric matches is `0`; an average is `#DIV/0!`; `MAXIFS`/`MINIFS` return `0`. ## SUMPRODUCT and SUBTOTAL `SUMPRODUCT(array1, [array2, …])` requires at least one argument. Every argument must have the same exact row-by-column shape; a scalar is a 1×1 shape, and a dimensional mismatch returns `#VALUE!`. It multiplies corresponding cells and sums the products. Text, booleans, and blanks reached through ranges contribute zero; directly supplied numeric text and booleans use ordinary scalar numeric coercion. Errors propagate, and a non-finite product or total returns `#NUM!`. `SUBTOTAL(function_number, reference1, [reference2, …])` accepts codes `1`–`11`: `AVERAGE`, `COUNT`, `COUNTA`, `MAX`, `MIN`, `PRODUCT`, `STDEV.S`, `STDEV.P`, `SUM`, `VAR.S`, and `VAR.P`. It accepts up to 253 reference arguments and uses the corresponding aggregate's range coercion/error rules. Codes `101`–`111` are deliberately unsupported and return `#VALUE!` pending hidden-row provenance in formula range values. Nested-subtotal and filtered/hidden-row exclusion must not be inferred. ## Financial functions `PV`, `FV`, `PMT`, `NPV`, `IRR`, `RATE`, `IPMT`, and `PPMT` use binary64 arithmetic, fixed defaults from the generated signatures, and explicit domain errors. `NPV` discounts its first cash flow at period 1 and uses compensated summation. Range cash flows ignore text and blanks; errors propagate. `IRR` requires at least one positive and one negative cash flow. `IRR` and `RATE` use the caller's guess (default `0.1`), 14 deterministic bracket steps, at most 100 deterministic solve steps, fixed absolute/relative tolerances of `1e-12`, and a finite transformed search domain. Invalid domains or failure to converge return `#NUM!`; no random seed, host locale, wall clock, or platform-specific iteration budget is used. ## Lookup semantics - `INDEX(range, row, [column])` uses 1-based indices and returns one scalar. Zero/negative indices return `#VALUE!`; array-return row/column projections are not implemented. - `MATCH(key, range, 0)` is exact. Match type `1` returns the largest value less than or equal to the key from ascending data. `-1` returns the smallest value greater than or equal to the key from descending data. Invalid ordering returns `#N/A` rather than a plausible wrong row. - `VLOOKUP`/`HLOOKUP` use exact matching when the final argument is false. The omitted/true mode uses correctly ordered approximate data and returns the largest value less than or equal to the key. - `XLOOKUP` supports exact, next-smaller (`-1`), next-larger (`1`), and wildcard (`2`) match modes; forward/reverse and ordered binary-search modes are accepted. `if_not_found` is optional. If it is omitted—including via an interior empty argument—the result is `#N/A`. Lookup errors in the scanned range propagate. Approximate modes validate ordering and do not silently return a result from unsorted input. ## Dynamic arrays and spills `FILTER`, `SORT`, `UNIQUE`, `TRANSPOSE`, `SEQUENCE`, `TAKE`, `DROP`, `CHOOSECOLS`, and `CHOOSEROWS` return rectangular values. A direct range formula such as `=A1:B4` also spills: - `FILTER(array, include, [if_empty])` accepts a one-column include range matching the array's rows or a one-row include range matching its columns. Shape mismatches are `#VALUE!`. No selected values returns `if_empty`, or `#CALC!` when omitted. - `SORT(array, [sort_index], [sort_order], [by_col])` defaults to the first column, ascending. `sort_index` selects the row/column key; `sort_order` is `1` or `-1`; `by_col=TRUE` sorts columns instead of rows. Multi-key sorting is unsupported. - `UNIQUE(array, [by_col], [exactly_once])` preserves first-seen order. `by_col=TRUE` compares columns; `exactly_once=TRUE` keeps only items occurring once. An empty result is `#CALC!`. - `TRANSPOSE(array)` swaps rows and columns. - `SEQUENCE(rows, [columns], [start], [step])` requires positive integer dimensions and defaults to one column, start `1`, step `1`; values fill row-major. - `TAKE`/`DROP(array, rows, [columns])` use positive counts from the start and negative counts from the end. A zero or empty result is `#CALC!`; counts beyond an axis clamp to that axis. - `CHOOSECOLS`/`CHOOSEROWS(array, index1, …)` use 1-based positive indices and end-relative negative indices. Repeated indices repeat output axes; zero/out-of-range indices return `#VALUE!`. Errors inside a returned array stay at their corresponding spill children; a mixed result such as `{1;#N/A;2}` does not collapse to an anchor-only error. The formula cell is the **anchor** and owns the complete runtime rectangle. Spill children have no formula source or persisted document identity. `store.getSpillAnchor(address)` returns the anchor for either the anchor or a child, and returns `null` for an ordinary cell. Spills publish atomically. Any nonempty destination, another spill, merge, validation or protected range, unloaded paged cell, or sheet boundary makes the anchor `#SPILL!`; no partial children remain. Styles, conditional formatting, and hidden rows/columns do not obstruct. Editing a projected child through canonical core mutations is rejected. Edit or clear the anchor instead. Structural and metadata changes invalidate ownership and recompute it. Snapshots and XLSX export serialize only the anchor formula. Children recompute after hydration. Internal rich copy preserves the anchor formula while treating children as derived blanks on paste; external TSV receives the displayed spill values. History snapshots likewise restore the anchor and recompute children, so undo never persists stale projections. Unsupported OOXML array/data-table formula records remain inert source text with an explicit warning rather than being silently treated as Sheetwrite spills. Each array dimension is capped at Excel's row/column limits, one spill is capped at 1,000,000 cells and 64 MiB of bounded value/intermediate storage, and one dynamic recompute pass is capped at 2,000,000 cell operations. Materialized spill ownership across the complete store is also capped at 1,000,000 cells and 64 MiB, including per-child error metadata. Installation checks that cumulative budget atomically; clearing, shrinking, or removing a spill releases its ownership. Oversized work returns stable `#NUM!` without partial children, while unstable or colliding shapes return an explicit error rather than truncating. The admission check happens before materialization when the shape is statically knowable and is repeated before spill ownership is installed. It accounts source/result copies and per-item auxiliary storage; a shape that could exceed a ceiling is rejected rather than optimistically allocated. A recompute either atomically replaces the complete previous spill or leaves no projected children. ## Unsupported categories Formula evaluation performs no network request and runs no custom JavaScript. Automatic volatile functions beyond the explicit `TODAY`/`NOW` host-clock barrier, network functions, external/live-data providers, arbitrary external workbook references, database functions, cube/OLAP functions, and `LAMBDA` or higher-order array execution are unsupported. Examples include `RAND`, `RANDBETWEEN`, `INDIRECT`, `OFFSET`, `WEBSERVICE`, `GOOGLEFINANCE`, `IMPORT*`, `RTD`, `DSUM`, `CUBEVALUE`, `LAMBDA`, `MAP`, `REDUCE`, and `SCAN`. Unsupported names evaluate to `#NAME?` while preserving source for snapshots and interchange. There is no compatibility shim or side-effecting fallback. The generated [unsupported-category ledger](https://sheetwrite.vercel.app/docs/reference/formula-functions/#unsupported-categories) is derived from the versioned inventory. ## Performance evidence No formula throughput or latency number is published from this guide because no checked final formula-performance evidence file is present. Follow the [performance evidence guide](https://sheetwrite.vercel.app/docs/guides/performance-resources/) to capture and freshness-check measurements; do not treat resource ceilings as benchmark results. ## Point mode and reference rewriting While editing a formula, clicking a cell inserts its A1 reference and dragging inserts a range. Relative references shift during fill; `$`-absolute axes remain fixed. Structural row/column edits rewrite direct and named references while preserving stable sheet identity. The same `$`-aware utility is public: ```ts import { shiftA1Refs } from "@sheetwrite/core"; shiftA1Refs("A1 + $B$1", 0, 1); // "B1 + $B$1" ``` A1 address helpers are exported as `cellA1`, `colToA1`, `labelToCol`, and `rangeA1`. ### Keep app rows in your store Source: https://sheetwrite.vercel.app/docs/guides/host-owned-rows/ Use the row bridge when your app owns the row objects and Sheetwrite owns editing, history, sorting, and filtering. The bridge is optional. Without `getRowId`, `defaultRows` keeps its existing one-time, uncontrolled behavior. ```ts import { createRowBridge, type RowBridgeProjection } from "@sheetwrite/core"; interface InvoiceRow { id: string; customer: string; total: number; } const entities = new Map([ ["invoice-1", { id: "invoice-1", customer: "Ada", total: 120 }], ["invoice-2", { id: "invoice-2", customer: "Lin", total: 75 }], ]); const rows = [...entities.values()]; let nextId = 3; const bridge = createRowBridge({ columns: [{ key: "customer" }, { key: "total" }], defaultRows: rows, getRowId: (row) => row.id, createRowId: () => `invoice-${nextId++}`, }); function applyProjection(projection: RowBridgeProjection) { if (projection.status === "rejected" || projection.status === "duplicate") return; for (const delta of projection.deltas) { if (delta.kind === "cell") { const { rowId, columnKey, next } = delta.cell; if (rowId === null || columnKey === null || next?.kind !== "literal") continue; const row = entities.get(rowId); if (row) entities.set(rowId, { ...row, [columnKey]: next.value }); } if (delta.kind === "row-structure" && delta.action === "delete") { for (const rowId of delta.removed) if (rowId !== null) entities.delete(rowId); } if (delta.kind === "row-structure" && delta.action === "insert") { for (const rowId of delta.inserted) { if (rowId !== null) entities.set(rowId, { id: rowId, customer: "", total: 0 }); } } } } ``` Attach the same bridge and callback in any adapter: ```tsx import { SheetwriteGrid, type RowBridge, type RowBridgeProjection, type SimpleColumn, } from "@sheetwrite/react"; interface InvoiceRow { id: string; customer: string; total: number; } declare const columns: readonly SimpleColumn[]; declare const rows: readonly InvoiceRow[]; declare const bridge: RowBridge; declare const applyProjection: (projection: RowBridgeProjection) => void; ``` Vue emits `row-delta`; Svelte and the imperative controller use `onRowDelta`. All adapters call the same core bridge. Sorts, filters, and hidden rows only change the visible order, so emitted row IDs still refer to data-space rows. Remote changes should enter through `grid.applyRemoteOperations(...)`. Their projections have `status: "remote"` and `source: "remote"`. An echoed local transaction is returned as `duplicate` with no deltas. For a server rejection or transformed acceptance, call `bridge.reconcile(...)` with the server result and the canonical applied operations. The bridge does not update your store automatically; your callback remains the only place that changes host rows. ### Interaction and editing Source: https://sheetwrite.vercel.app/docs/guides/interaction/ [Installation](https://sheetwrite.vercel.app/docs/start/installation/) Sheetwrite handles selection, keyboard navigation, inline editing, the clipboard, the toolbar, merges, and drag-to-fill out of the box. The host element is made focusable (`tabindex="0"`) so it receives keyboard events. Setting `readOnly: true` disables every mutating interaction below (editing, clearing, fill, paste, and restyling) while leaving navigation and selection intact. ## Selection model A `Selection` is one of five shapes: ```ts type Selection = | { kind: "cell"; addr: CellAddress } | { kind: "range"; range: Range } | { kind: "row"; sheet: SheetId; row: number } | { kind: "column"; sheet: SheetId; col: number } | { kind: "multi"; ranges: Range[] }; ``` Read or set it imperatively, and subscribe to changes: ```ts const sel = grid.getSelection(); grid.setSelection({ kind: "cell", addr: { sheet: "sheet1", row: 0, col: 0 } }); grid.setSelection(null); // clear grid.on("selection", (e) => console.log(e.selection)); ``` How selections are made with the mouse (primary button): | Gesture | Result | | --- | --- | | Click a cell | Select that cell. | | Drag across cells | Extend to a **range**. | | Shift-click | Extend the range from the current anchor. | | Ctrl/Cmd-click | Add a region — produces a **multi** selection. | | Click a column letter (top header) | Select that **column** (Shift extends; Ctrl/Cmd adds). | Row and full-column selections are part of the model. Column selection is wired to the top header; **row selection is programmatic only** — there is no row-header click gesture, so set it via `setSelection({ kind: "row", sheet, row })`. ## Keyboard navigation When the grid is focused and not editing: | Key | Action | | --- | --- | | Arrow keys | Move the focus one cell. | | Ctrl/Cmd + Arrow | Jump to the first/last row or column. | | Page Up / Page Down | Move by a viewport of rows. | | Home / End | Move to the first / last column of the row. | | Ctrl/Cmd + Home / End | Move to the sheet's first / last cell. | | Shift + any of the above | Extend the selection instead of moving. | | Enter or F2 | Edit the focused cell (F2 selects the existing text). | | Delete / Backspace | Clear the selected cells. | | A printable character | Start editing, replacing the cell's content. | | Ctrl/Cmd + C / X / V | Copy / cut / paste. | | Ctrl/Cmd + Z | Undo the last edit. | | Ctrl/Cmd + Shift + Z (or Ctrl/Cmd + Y) | Redo. | | Ctrl/Cmd + F | Open the find bar (when `config.find` is not `false`). | ## Inline editing Editing happens in a single real `