Sqwig documentation

Sqwig is a drop-in rich text box for web apps — a Word-tier formatting toolbar, clean paste, and dictionary spellcheck that runs entirely on the device. Text never leaves the browser: the engine, its Web Worker, and the dictionary are static files served from your origin, and the component makes zero calls to Sqwig servers.

Never blocking. If the dictionary fails to load for any reason, the editor degrades to a plain rich text input. It never throws at your users and never gates typing on a network.

Installation & assets

terminal
# core editor (framework-agnostic)
npm i @sqwig/core

# optional: the React wrapper
npm i @sqwig/react

The one rule every stack shares

Sqwig ships three runtime assets in @sqwig/core/dist: the WASM engine (spell_wasm_bg.wasm), its Web Worker (spell.worker.js), and the dictionary chunks (dict/). They must be served files at runtime — a bundler cannot inline them. So every integration is the same two steps:

  1. Copy those files into a folder your app serves statically (e.g. /sqwig/).
  2. Point the spellchecker at it: createSpellChecker({ assetBaseUrl: "/sqwig/" }).
Shortcut: if you copy the whole dist/ folder, the module and its assets stay co-located and you can omit assetBaseUrl entirely — assets resolve beside the module by default.

Quickstart

Any ES-module page — no bundler required. This produces a working, spellchecking editor:

index.html
<div id="editor"></div>
<script type="module">
  import { createEditor, createSpellChecker } from "/sqwig/index.js";

  const editor = createEditor(document.getElementById("editor"), {
    placeholder: "Say something…",
    spell: createSpellChecker(), // assets resolve beside index.js
    trustIndicator: { wordCount: true },
  });

  editor.on("change", () => console.log(editor.getHTML()));
</script>

Here /sqwig/ is a wholesale copy of node_modules/@sqwig/core/dist/. Using React, Angular, or .NET? See Frameworks.

Spellcheck & suggestions

On-device checking, live vs. manual modes, and the suggestion popover.

Sqwig checks spelling with a WebAssembly engine that runs in a Web Worker, so building the ~380K-term index never blocks your page. Misspelled words get the red wavy underline; clicking or tapping one opens a popover with up to three case-matched suggestions plus Ignore (this instance) and Add to dictionary (document-wide).

Live vs. manual

Checking is live as the user types by default. Pass mode: "manual" to sweep only when you call editor.checkNow().

Observing state

The "spell-state" event reports "checking-on-device" or "plain-input" (the degraded mode when the dictionary could not load).

Paste from Word / Google Docs

What survives a paste, what converts, and how tables and images are handled.

  • Survives: bold, italic, lists, links, paragraphs, headings.
  • Converts: H4–H6 → H3; fonts/colors → clean default; vendor markup stripped.
  • Tables: render as clean tables with editable cell text (no table authoring in v1 — structure commands are disabled inside cells, and pastes into a cell insert as text).
  • Images: dropped with an [image] placeholder in v1.

Ctrl/Cmd+Shift+V pastes as plain text. Output is always the clean subset in the output contract — never garbage.

Mobile

iOS Safari and Android Chrome are first-class, release-blocking targets — verified on real devices.

  • Native autocorrect interplay is handled: the OS keyboard neither substitutes words under Sqwig's squiggles nor paints a second underline.
  • On touch, the toolbar becomes a single horizontally scrollable row (like Word and Docs on a phone) with ≥40px tap targets.
  • Popovers measure the visual viewport, so the on-screen keyboard never hides a suggestion — they clamp to the screen and flip above the word when space below runs out.

No configuration required for any of this.

Styling & theming

Every visual knob is a CSS custom property on .sqwig-editor.

your-app.css
.sqwig-editor {
  --sqwig-accent: #7c3aed;   /* brand color: focus, active states, links */
  --sqwig-radius: 10px;
  --sqwig-font: "Inter", sans-serif;
}
VariableRole
--sqwig-accentFocus border, active controls, links (--sqwig-focus derives from it).
--sqwig-border / --sqwig-radiusComponent and control borders.
--sqwig-font / --sqwig-text / --sqwig-bg / --sqwig-mutedTypography and surfaces.
--sqwig-toolbar-bg / --sqwig-toolbar-fgFooter ground; toolbar icon/label ink.
--sqwig-hover-bg / --sqwig-active-bgControl hover pill and pressed tint.
--sqwig-focus-ringThe soft halo while the editor has focus.
--sqwig-squiggleThe red. Change at your own risk — it's the brand.
--sqwig-warnmaxLength counter's warning color.

Dark mode

Pass theme: "dark", or stamp data-sqwig-theme="dark" on any ancestor (e.g. <html>) when your app toggles at runtime. The preset re-pins every variable and sets color-scheme: dark; your individual overrides still win.

Privacy

What the component sends to Sqwig servers: nothing.

User text never leaves the browser — and the component makes no network calls to Sqwig at all. Your license key is a signed token verified offline, on the device; the only fetches are your own origin's static assets (engine, worker, dictionary). You can verify this in your browser's network tab. See the full privacy policy.

React

@sqwig/react exports <SqwigEditor>: a thin, uncontrolled wrapper. Every core option is a prop (read at mount); onChange / onSelectionChange / onSpellState forward the core events; the ref exposes the full core Editor. StrictMode-safe.

Compose.tsx
import { useMemo, useRef } from "react";
import { SqwigEditor } from "@sqwig/react";
import { createSpellChecker, type Editor } from "@sqwig/core";

export function Compose() {
  const editor = useRef<Editor>(null);
  // Create ONCE — not inline in JSX (that would spawn a Worker per render).
  const spell = useMemo(() => createSpellChecker({ assetBaseUrl: "/sqwig/" }), []);

  return (
    <SqwigEditor ref={editor} spell={spell}
      placeholder="Say something…"
      onChange={(html) => save(html)} />
  );
}

With Vite, copy the three assets into public/sqwig/. Vue, Svelte, and web-component wrappers are planned fast-follows on the same core.

Angular

Wrap createEditor in a standalone component: create in ngAfterViewInit, destroy in ngOnDestroy. Publish the three assets to /sqwig/ (copy into public/, or per-glob from node_modules via angular.json).

sqwig-editor.component.ts
ngAfterViewInit(): void {
  this.editor = createEditor(this.host.nativeElement, {
    placeholder: "Say something…",
    spell: createSpellChecker({ assetBaseUrl: "/sqwig/" }),
  });
  this.editor.on("change", () => this.changed.emit(this.editor!.getHTML()));
}
ngOnDestroy(): void { this.editor?.destroy(); }

ASP.NET Core (Razor Pages / MVC)

No bundler needed — @sqwig/core ships browser-ready ES modules. Copy node_modules/@sqwig/core/dist/wwwroot/sqwig/ and import directly; co-location means no assetBaseUrl.

No npm in your workflow? Download the same files as a zip — sqwig-core-0.1.0.zip (contains dist/ plus license and third-party notices) — and unzip its dist/ to wwwroot/sqwig/.
Compose.cshtml
<div id="editor"></div>
<input type="hidden" name="Body" id="body-field" />
<script type="module">
  import { createEditor, createSpellChecker } from "/sqwig/index.js";
  const editor = createEditor(document.getElementById("editor"), { spell: createSpellChecker() });
  editor.on("change", () => { document.getElementById("body-field").value = editor.getHTML(); });
</script>

Kestrel serves .wasm with the right MIME type out of the box. Hosting under IIS, add <mimeMap fileExtension=".wasm" mimeType="application/wasm" /> to web.config. Treat the posted HTML like any rich text input server-side (it's the whitelisted subset, but defense-in-depth is yours).

Blazor (Server or WebAssembly)

Drive Sqwig through a small JS interop shim — a module in wwwroot exposing mount / getHTML / setHTML / destroy over a Map of editor ids — loaded with IJSRuntime.InvokeAsync<IJSObjectReference>("import", "/sqwig-interop.js") in OnAfterRenderAsync, disposed via IAsyncDisposable. Assets go in wwwroot/sqwig/ exactly as above.

Blazor Server: every interop call is a network round-trip — read the HTML on submit, not on every keystroke.

The complete shim and component are in the developer guide that ships with the repo.

createEditor options

createEditor(host, options)Editor. All options are optional.

OptionType / defaultWhat it does
placeholderstringGhost text when empty; also the region's accessible label.
initialHTMLstringSeeds the document (sanitized through the whitelist on the way in).
toolbarboolean = trueThe batteries-included formatting toolbar.
trustIndicatorboolean | { text?, wordCount? } = trueFooter with the privacy note; wordCount: true adds live counts.
theme"light" | "dark" = "light""dark" applies the built-in dark preset (see theming).
spellSpellCheckerLike | Promise | nullFrom createSpellChecker(...); null disables spellcheck. A failed load degrades to plain input.
mode"live" | "manual" = "live"Live squiggles, or sweep on demand via checkNow().
readOnlyboolean = falseStart read-only (toggle later with setReadOnly).
maxLengthnumberHard character cap: typing blocked past it, pastes truncated, counter warns as it fills.
licensestringYour signed key. Omit = Free tier (badge shown). Verified offline; invalid keys fall back to Free — never throw, never phone home.

Editor methods & events

MemberNotes
getHTML() / setHTML(html) / getText()Output is always the whitelisted subset.
exec(command, arg?) / queryState(command)Programmatic formatting — the same commands the toolbar uses ("bold"; "heading", "h2"; "link", url; …).
on(event, handler) → unsubscribeEvents: "change", "selectionchange", "spell-state" (payload "checking-on-device" | "plain-input").
checkNow()Manual-mode sweep (no-op in live mode).
focus()Focus the editing region.
setReadOnly(bool) / isReadOnly()Runtime read-only toggle: region uneditable, toolbar disabled, popovers suppressed.
destroy()Unmount and remove all listeners.
regionThe contenteditable element (advanced integrations).

createSpellChecker options

OptionDefaultNotes
assetBaseUrlmodule-relativeDirectory your copied assets are served from.
workerUrl./spell.worker.jsExplicit worker URL; overrides assetBaseUrl.
wasm./spell_wasm_bg.wasmExplicit engine source.
coreDict / tailDict./dict/en_US.core.txt / .tail.txtDictionary chunks; tailDict: null skips the tail (smaller download, slightly lower recall).

Output contract

getHTML() emits only: p, h1–h3, strong, em, u, s, sub, sup, span[style: font-family/font-size], ul, ol, li, a[href], br, table, tr, td — with block text-align/line-height/margin-left, list list-style-type, and scheme-checked hrefs. Every attribute is re-validated at the exit boundary, so the output is safe to store and re-render as-is.

License keys

Without a key Sqwig runs in the Free tier and shows a small "Powered by Sqwig" badge in the footer. Paid keys remove it:

app.js
createEditor(host, { license: "sqwig-lk1.…" });

Keys are Ed25519-signed tokens verified offline inside the bundle — domain-locked, version-windowed in your favor (versions released during an active subscription keep working forever), and fail-open: any verification problem means Free tier plus a single console note, never a broken editor and never a network call. No per-developer fees, no usage metering. Pricing: sqwig.com.

Content-Security-Policy

Everything is same-origin static files, so a typical strict CSP needs only script-src 'self' 'wasm-unsafe-eval' (WebAssembly compilation) and worker-src 'self'. Sqwig makes no third-party requests of any kind.

Changelog

0.1.0 — pre-release

  • Word-tier toolbar (always-active), live squiggles with suggestion popover, clean Word/Google-Docs paste, autoformat, autolink, read-only mode, maxLength.
  • Spell engine in a Web Worker (no main-thread jank); split dictionary; degrades to plain input if assets fail.
  • Mobile: iOS Safari + Android Chrome verified on real devices; scrollable touch toolbar; keyboard-aware popovers.
  • Light + dark themes; full CSS-variable theming; offline license keys with the Free-tier badge.
  • @sqwig/react wrapper.