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
PackageTake it whenShips
@opencalc/sheetyou want the editor on a page — this is the one almost everybody needsthe custom element, its stylesheet, the wasm binary and fonts, the opencalc-assets CLI
@opencalc/reactyou are on React and want props and onEvent handlers instead of imperative callsa ~70-line wrapper; depends on @opencalc/sheet
@opencalc/engineyou need to read, recalculate or write a workbook with no UI — a server route, a worker, a testthe 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.

  1. Install the package

    No peer dependency on a UI framework, and no build-time plugin to register.

    npm install @opencalc/sheet
  2. 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" }
  3. 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.

  4. Wait for it, then configure and load

    Booting the engine is asynchronous. ready resolves 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");
  5. Persist what changes

    Listen, debounce, save. The source check 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:

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"
});
OptionWhat it does
calculationWhen 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.
accessEdit, view-only, or preview. See below — the last two are not the same thing.
assets-urlAttribute, 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.

viewpreview
What it isan access levela presentation
Chromeall of it, minus commands that writenone
Select and copyyesyes
Zoom, sheets, find, exportyesno
Fora permission system's read-onlya 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 idcommand.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();
});
EventCancellablePayload
beforeCellsChanged / cellsChangedbefore 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';
DirectiveWhy
'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-srcThe 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-srcThe 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.

Troubleshooting

What you seeWhat it is
The element renders nothing, no errorsNo height. It has no intrinsic size — style="height:600px", or a sized flex/grid parent.
404 on casual_calc_wasm_bg.wasmThe 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 buildServer 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 WebAssemblyscript-src is missing 'wasm-unsafe-eval'. See CSP.
Every cell renders in a fallback fontThe @font-face hoist was blocked by style-src. Pass the nonce attribute.
Dark mode ignores your palettetheme({ 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 loopMissing the source check in cellsChanged. Your own writes come back as source: "api".
Config props remount the element every renderA 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 placeAn 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 loadingFixed in current versions — each mount gets its own engine instance. If you are pinned to an early alpha, upgrade.

Limits and caveats

Design rationale, including what was considered and rejected, is in docs/55. Runnable examples are in sdk/examples.