SDK
Run it before you read this
The fastest way to understand what you are integrating is to watch it work.
docker compose up in the repository gives you the editor, a
collaboration server and a reference host; open a document, press Share, and
co-edit it with somebody. The host you are running is the worked example of
the one this guide asks you to write —
four endpoints and a signature.
See the self-hosting guide.
Integration guide
Put a real spreadsheet in your application — reading and writing
.xlsx, 347 worksheet functions, pivot tables and charts —
in about five lines. The engine is Rust compiled to WebAssembly and
runs entirely in your user's browser.
Alpha — 0.0.x is a preview. Everything on this
page is published and works, but the API is not stable: while we
are pre-1.0 a rename is a minor version, not a major one. Pin the
version you test against. There is no collaborative editing here —
this is a single-user view-and-edit surface.
Installation
# the editor as a custom element npm install @opencalc/sheet # React bindings (optional — the element works anywhere) npm install @opencalc/react # the engine alone, no DOM and no CSS, for headless work npm install @opencalc/engine
| Package | Take it when | Ships |
|---|---|---|
@opencalc/sheet | you want the editor on a page — this is the one almost everybody needs | the custom element, its stylesheet, the wasm binary and fonts, the opencalc-assets CLI |
@opencalc/react | you are on React and want props and onEvent handlers instead of imperative calls | a ~70-line wrapper; depends on @opencalc/sheet |
@opencalc/engine | you need to read, recalculate or write a workbook with no UI — a server route, a worker, a test | the wasm engine and its JS bindings; no DOM, no CSS |
Integrate in five steps
End to end, from an empty project to a sheet your users can edit and you can persist. Every step below is exercised by the runnable examples.
-
Install the package
No peer dependency on a UI framework, and no build-time plugin to register.
npm install @opencalc/sheet
-
Put the engine on your own origin
With Vite, webpack, Rollup or Parcel there is nothing to do — skip to step 3. With Next.js, or any host serving static files it did not bundle, copy the assets and tell the element where they landed.
// package.json "scripts": { "postinstall": "opencalc-assets ./public/opencalc" }
-
Import once, then use the tag anywhere
The import registers
<opencalc-sheet>as a custom element. Do it once, at your app entry point — a second import is a no-op, not a second registration.import "@opencalc/sheet"; <opencalc-sheet id="sheet" style="height: 600px"></opencalc-sheet>
It has no intrinsic height. Give it one — a pixel height, a flex child that stretches, or
height: 100%inside a sized parent. Left alone it collapses to zero and looks like a failed load. -
Wait for it, then configure and load
Booting the engine is asynchronous.
readyresolves once the grid is live; calls made before it are queued rather than lost, but reading state back needs it.const sheet = document.getElementById("sheet"); await sheet.ready; await sheet.configure({ access: "edit", calculation: "auto" }); sheet.theme({ light: { accentColor: "#7c3aed" } }); sheet.chrome({ statusbar: false }); const bytes = await (await fetch("/api/files/42.xlsx")).arrayBuffer(); await sheet.open(bytes, "budget.xlsx");
-
Persist what changes
Listen, debounce, save. The
sourcecheck is not optional — without it a host that saves on change and loads on mount echoes its own writes back to itself forever.let timer; sheet.on("cellsChanged", (e) => { if (e.source === "api") return; clearTimeout(timer); timer = setTimeout(async () => { await fetch("/api/files/42.xlsx", { method: "PUT", body: await sheet.save() }); }, 1500); });
That is the whole integration. Everything below is refinement — which controls to show, what it looks like, what happens when the user may not write.
A complete page
Copy this into a file, serve it, and you have a working spreadsheet with a file picker and a download button. Nothing else required.
<!doctype html> <input type="file" id="pick" accept=".xlsx,.csv"> <button id="dl">Download</button> <opencalc-sheet id="sheet" style="display:block;height:70vh"></opencalc-sheet> <script type="module"> import "@opencalc/sheet"; const sheet = document.getElementById("sheet"); await sheet.ready; pick.onchange = async (e) => { const file = e.target.files[0]; await sheet.open(await file.arrayBuffer(), file.name); }; dl.onclick = async () => { const url = URL.createObjectURL(new Blob([await sheet.save()])); Object.assign(document.createElement("a"), { href: url, download: "out.xlsx" }).click(); URL.revokeObjectURL(url); }; </script>
The element gives itself a shadow root, so your stylesheet cannot reach into it and its stylesheet cannot reach out onto your page.
Serving the engine
The WebAssembly binary and the bundled fonts are served from your own origin. Not from a CDN we run. Two reasons, and the second is the one that matters:
- the cache headers on a multi-megabyte binary should belong to whoever pays for the traffic;
- a Web Worker cannot be constructed from a cross-origin URL, so a CDN would foreclose ever moving the engine off the main thread.
Vite, webpack, Rollup, Parcel
Nothing to do. The package resolves its assets with
new URL(…, import.meta.url), which these bundlers emit as
hashed files from your build output.
Next.js
Turbopack does not treat .wasm as an emitted
asset, so import.meta.url resolution does not
produce a servable file. Copy the assets into public/
instead — this is what the Next community settled on for every wasm
package, not something peculiar to us.
// package.json "postinstall": "opencalc-assets ./public/opencalc"
<opencalc-sheet assets-url="/opencalc/" />
Re-run it after upgrading: npm update bumps the
JavaScript and leaves public/ alone, so the two can
drift. Keeping the copy in postinstall is what stops
that; see Versions and upgrades.
Options
await sheet.configure({ calculation: "auto", // | "manual" — default: whatever the file asks for access: "edit", // | "view" | "preview" });
| Option | What it does |
|---|---|
calculation | When formulas recompute. Taken from the file's own <calcPr> unless you override it — a workbook saved with calculation off opens that way, because its author turned it off for a reason. |
access | Edit, view-only, or preview. See below — the last two are not the same thing. |
assets-url | Attribute, not option: read when the element mounts. |
Theming
Themes are CSS custom properties on the element. They are the one
thing that crosses a shadow boundary, which is what makes them the
API rather than an implementation detail. Names are typed by suffix —
Color takes a colour, Shadow a box-shadow,
FontFamily a font stack.
sheet.theme({ accentColor: "#7c3aed" }); // both schemes sheet.theme({ // per scheme light: { backgroundColor: "#fbf9f4" }, dark: { backgroundColor: "#17150f" }, }); sheet.setColorScheme("dark"); // | "light" | "auto" sheet.resetTheme(); // back to the defaults
Use the per-scheme form for anything but an accent. Tokens
are written as inline custom properties, and an inline style beats
every rule in the stylesheet — including the dark-mode block. Set
backgroundColor once and you have set it for dark mode
too.
Calls merge, so you can set one token without restating the rest;
resetTheme() clears them. An unknown token name throws,
rather than quietly not changing a colour.
Chrome and commands
Whole regions:
sheet.chrome({ toolbar: false, statusbar: false });
header, menubar, toolbar,
formulabar, tabs, statusbar,
localePicker.
The app header is off by default when embedded — the brand mark
and settings gear belong to our demo page, not to your product.
Or individual controls, by id:
sheet.commands({ hidden: ["file.open", "insert.pivottable"], disabled: ["insert.chart"], }); await sheet.listCommands(); // all 189 ids
Hidden and disabled differ on purpose. A capability you have not implemented yet should be disabled — someone who cannot see a thing assumes it does not exist and stops looking. One that makes no sense in your product should be hidden.
Ids come from the English label path: Format ▸ Alignment ▸
Left is format.alignment.left. Renaming a label
renames its id, and we treat that as a breaking change.
Read-only and preview
Two different things. Conflating them gives you both bad outcomes at once: a viewer that reads as a broken editor because it is full of greyed-out menus, and a thumbnail that invites clicking on things it will refuse.
view | preview | |
|---|---|---|
| What it is | an access level | a presentation |
| Chrome | all of it, minus commands that write | none |
| Select and copy | yes | yes |
| Zoom, sheets, find, export | yes | no |
| For | a permission system's read-only | a thumbnail, a file-list row |
Both refuse writes in the engine, not by hiding buttons. A read-only mode enforced only in the UI is read-only right up until somebody calls the API.
Localization
Two injection points, because they serve two different people.
You supply it, when your product already knows the user's language:
await sheet.configure({ locale: "de-DE", messages: { "de-DE": { "command.file": "Datei", "command.edit.undo": "Rückgängig", "tip.toolbar.bold": "Fett", }, }, });
The user chooses it, from a control in the footer — off by default, because most hosts drive the language from their own account settings and a second control that disagrees with the first is worse than none:
sheet.chrome({ localePicker: true });
Message keys
Keyed by command id — command.format.alignment.left
for a menu item, tip.toolbar.bold for a toolbar tooltip.
Ids come from the English label, so translating never renumbers
the command API: format.bold stays format.bold
for a German host and your commands({ hidden }) lists keep
matching.
A key with no translation falls back to the English string, so a
partial catalogue degrades to "some of it is translated" rather than
to visible keys. Alt mnemonics are recomputed per language — the
File menu is Alt+F in English and Datei is
Alt+D in German.
Coverage today: menus, submenus and toolbar tooltips. Panels, dialogs and status messages are still English regardless of locale — their catalogues are not written yet. Number and date display already follows the format's own language and always has.
Function names are not localized — SUM is
SUM everywhere. Doing that halfway is worse than not
doing it: someone who types SUMME and gets
#NAME? concludes the feature is broken, and a file that
stores a localized name is unreadable everywhere else.
API
await sheet.ready; // resolves once the grid is up await sheet.open(bytes, name); // .xlsx, .csv, .tsv, .psv await sheet.save(); // .xlsx bytes sheet.theme(tokens); sheet.resetTheme(); sheet.chrome(regions); await sheet.commands(rules); sheet.setColorScheme(scheme); await sheet.configure(options); sheet.access; // "edit" | "view" | "preview"
Everything returns a promise, in every transport.
Events
const stop = await sheet.on("cellsChanged", (e) => { if (e.source === "api") return; // our own write — do not loop save(e.range); }); // before* events can be cancelled sheet.on("beforeCellsChanged", (e) => { if (!user.canEdit(e.range)) e.preventDefault(); });
| Event | Cancellable | Payload |
|---|---|---|
beforeCellsChanged / cellsChanged | before only | { sheet, range, value, source } |
selectionChanged | — | { sheet, range, activeCell } |
calculationChanged | — | { mode, needsRecalculation } |
undoStateChanged | — | { canUndo, canRedo } |
Always check source. A host that persists on
change and loads on mount will echo its own writes back to itself
forever without it. It is one of user, api,
paste, fill, undo,
redo, import.
One event per operation, carrying a range — a paste of a hundred thousand cells is one event, not a hundred thousand. A listener that throws is caught and logged: your bug in a change handler must not take the grid down with it.
React
import { OpenCalcSheet } from "@opencalc/react"; <OpenCalcSheet style={{ height: 600 }} engine={{ calculation: "manual" }} ui={{ theme: { light: { accentColor: "#7c3aed" } } }} onCellsChanged={handleChange} />
The wrapper exists for three specific reasons, each of which is a bug in someone's React wrapper right now: config objects are new identities every render and must not cause a remount; Strict Mode mounts twice in development; and React's synthetic event system does not carry custom DOM events. On React 19 object props reach a custom element as properties, on 18 they stringify — the wrapper never relies on that, so it behaves the same on both.
The whole wrapper is about seventy lines — read it and copy it if you want to change how config is diffed.
Next.js
const Sheet = dynamic(() => import("./SheetClient"), { ssr: false });
"use client" is not enough on its own: a client component
is still rendered on the server for the initial HTML, and the element
touches window at import. Plus the asset copy above.
Vue and Svelte
No wrapper needed — both pass objects to custom elements as properties and listen for DOM events natively.
<!-- Vue --> <opencalc-sheet ref="sheet" @cellsChanged="onChange" />
CSP and headers
A policy that lets the engine start, with everything on your origin:
Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self';
| Directive | Why |
|---|---|
'wasm-unsafe-eval' | Compiling a WebAssembly module counts as evaluation. This is the narrow keyword that permits wasm and nothing else — it does not re-enable eval(). Do not reach for 'unsafe-eval'. |
style-src 'unsafe-inline' | Two uses, and you can narrow both. See below. |
font-src | The bundled metric-compatible faces — Carlito for Calibri, Caladea for Cambria — are what make a cell render the same on a machine that has neither. |
connect-src | The element fetches its own markup, stylesheet and wasm binary from the assets base. If that base is a different origin, name it here. |
Narrowing style-src
The SDK injects exactly one <style> element: the
bundled @font-face rules, hoisted into the document
because a font face declared inside a shadow root is never registered.
Give the element your nonce and that one is covered:
<opencalc-sheet nonce="{{cspNonce}}"></opencalc-sheet>
What a nonce cannot cover is the style attribute: theme tokens
are written as inline custom properties, and CSP nonces do not apply
to attributes. Under a policy without 'unsafe-inline' the
grid renders correctly in its default palette and only your
theme() overrides are dropped. If that trade is the wrong
way round for you, set the tokens from your own stylesheet instead —
custom properties inherit through a shadow boundary, which is the
whole reason they are the theming API:
opencalc-sheet { --oc-accent-color: #7c3aed; }
Nothing needs frame-src, img-src blob: or
worker-src today — the grid is a canvas, not an iframe,
and the engine runs on the main thread. worker-src 'self'
is worth allowing now if your policy is hard to change later.
Versions and upgrades
Two artefacts have to agree: the JavaScript from
node_modules, and the wasm binary wherever you copied it.
On the bundler path they move together. On the
public/ path they can drift, because
npm update touches one and not the other.
There is no version check between them yet. A skew today
surfaces as a function that quietly returns the wrong answer, which
is the expensive kind of failure — so the copy belongs in
postinstall, not in a runbook. Failing loudly at load
on a mismatch ships with the npm packaging.
- Re-run the asset copy after every upgrade. Keeping it in
postinstallmeans you cannot forget. - Pin during alpha. The element name, the token names and the command ids are the API surface, and while we are pre-1.0 a rename is a minor version, not a major one.
- Command ids follow English labels. Renaming a menu label renames its id; we treat that as breaking and put it in the changelog.
- Nothing phones home. No auto-update, no version check against a server, no telemetry. Upgrading is entirely your call and entirely your responsibility.
Troubleshooting
| What you see | What it is |
|---|---|
| The element renders nothing, no errors | No height. It has no intrinsic size — style="height:600px", or a sized flex/grid parent. |
404 on casual_calc_wasm_bg.wasm | The assets are not where the element is looking. On Next.js run opencalc-assets ./public/opencalc and set assets-url="/opencalc/"; elsewhere check that your bundler emitted the package's assets. |
window is not defined during build | Server rendering. "use client" is not enough — a client component still renders on the server for the initial HTML. Use dynamic(..., { ssr: false }). |
| Console: refused to compile WebAssembly | script-src is missing 'wasm-unsafe-eval'. See CSP. |
| Every cell renders in a fallback font | The @font-face hoist was blocked by style-src. Pass the nonce attribute. |
| Dark mode ignores your palette | theme({ backgroundColor }) sets it for both schemes, and an inline property beats the stylesheet's dark block. Use the { light, dark } form. |
| Your save handler runs forever in a loop | Missing the source check in cellsChanged. Your own writes come back as source: "api". |
| Config props remount the element every render | A new object literal each render, passed to the raw element. The React wrapper diffs by value for exactly this reason. |
| Menus open in the wrong place | An ancestor with contain: layout, transform, filter or perspective becomes the containing block for fixed positioning, and dropdowns anchor to it. This is a CSS fact about your page, not about the element. |
| A second element on the page never finishes loading | Fixed in current versions — each mount gets its own engine instance. If you are pinned to an early alpha, upgrade. |
Limits and caveats
- Each element is its own engine. The editor keeps state at module scope, so every mount gets its own module and its own wasm heap. Three thumbnails are fine; fifty should be paged in and out rather than mounted at once.
- No collaborative editing in this SDK. It is a single-user view-and-edit surface. Multi-user editing is a server-side product with a different shape.
- A pivot created here exports as its cells, not as a live Excel pivot object. Figures, formatting and layout are all correct and open anywhere; the definition survives in our own snapshot format.
- Custom worksheet functions are not supported yet. A JavaScript callback would make recalculation non-reproducible, which is a guarantee we are not ready to give up quietly.
- Keeping your integration current is yours. No auto-update and nothing phoning home. We publish versions and a changelog.
Design rationale, including what was considered and rejected, is in docs/55. Runnable examples are in sdk/examples.