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 },
    license: "free", // accepts the Free License — sqwig.com/free-license
  });

  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 ~83,000-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 (that one occurrence, for the session) and Add to dictionary (every editor on your origin, and it persists — see userDictionaryStorageKey).

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).

Code, quotes & strikethrough

Markdown-style triggers for the parts of a document a front-end developer writes: inline code, code blocks, and blockquotes.

TriggerResult
`text`Inline <code> on the closing backtick.
``` at the start of a line, then space or EnterA code block (<pre>).
> + space at the start of a lineA blockquote.
~~text~~Strikethrough (<s>) — completes the **bold** / *italic* / `code` / ~~strike~~ family.

Inside a code block, Enter inserts a newline (never a new block) — except on a trailing empty line, where Enter exits into a new paragraph below it. Backspace in an empty code block converts it back to a paragraph. Every other formatting command is disabled while the caret is inside one. Inside a blockquote, Enter on the quote's empty last paragraph exits the quote the same way.

Spellcheck skips code. Words inside inline code and code blocks are never checked — useEffect is never a typo.

The </> inline-code button sits in the fixed toolbar and in the floating balloon (see the toolbar option); code block and blockquote are fixed-toolbar buttons.

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.

Pasting from a PDF

What a PDF puts on the clipboard depends on the reader. Adobe Acrobat Reader offers a rich flavour carrying the document's paragraphs, bold and bullets, and Sqwig reads it. Other readers offer plain text only — one visual line per line of the page, broken where the page measure ran out. Sqwig pastes what it is given and does not invent structure, so a plain-text PDF arrives with its line breaks intact rather than reflowed by guesswork.

When that happens, Clean up text rebuilds the structure.

Clean up text

A toolbar button that recovers structure from text pasted as plain lines.

It rejoins hard-wrapped lines into sentences, closes up hyphenated splits, turns bullet lines into a real list, promotes ALL CAPS section titles to headings, and drops page numbers and repeated running headers. It acts on the selection, or on the whole document when nothing is selected, and one Ctrl/Cmd+Z puts everything back.

Before — pasted from a PDF that offered plain text only:

pasted
EXECUTIVE SUMMARY
Technology executive who most recently led a
150-person global engineering organization deliv-
ering ~$200M in annual revenue.
Highlights
Scaled the flagship platform from $57M to $100M
Consolidated four billing systems into one
Page 1 of 3

After — one click:

getHTML()
<h2>Executive summary</h2>
<p>Technology executive who most recently led a 150-person
global engineering organization delivering ~$200M in annual
revenue.</p>
<h2>Highlights</h2>
<ul>
  <li>Scaled the flagship platform from $57M to $100M</li>
  <li>Consolidated four billing systems into one</li>
</ul>

It is deliberately conservative: it only rewrites paragraphs that are plain text, so a bold run, a link, a table or a code block passes through untouched — and it greys out when there is nothing to recover. Programmatically it is exec("cleanUpText").

On touch devices the button sits at the start of the toolbar, since the bar scrolls horizontally there and the end of it is off screen.

Mobile

iOS Safari and Android Chrome are supported and tested 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}
      license="free" // accepts the Free License
      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/" }),
    license: "free",
  });
  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-1.0.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(), license: "free",
  });
  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. license is required; everything else is 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 | "fixed" | "floating" = truetrue"fixed". "floating" shows a compact balloon (B, I, U, S, inline code, Link) at the selection instead of the fixed bar — desktop-only (touch devices always get the fixed toolbar).
trustIndicatorboolean | { text?, wordCount? } = trueFooter with the privacy note; false hides it, text rewords it, wordCount: true adds live counts. Doesn't affect the Free-tier badge. Nuances: the trust footer.
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.
license (required)"free" | signed key"free" accepts the Free License (clean badge); a signed key applies your tier's entitlements. Anything else — including omitting it, which only plain JS can do — runs the free tier with the badge reading "· unlicensed". Verified offline; never throws, never phones home. Detail: Licensing.

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).
userDictionaryStorageKey"sqwig:user-dictionary"Where Add to dictionary words persist, in the browser's own localStorage. Set null to keep added words to the session and write nothing.

Output contract

getHTML() emits only: p, h1–h3, strong, em, u, s, sub, sup, code, span[style: font-family/font-size], ul, ol, li, a[href], br, table, tr, td, pre, blockquote — with block text-align/line-height/margin-left, list list-style-type, and scheme-checked hrefs. Code blocks serialize as semantic <pre><code>…</code></pre> with no syntax highlighting. Every attribute is re-validated at the exit boundary. See the security model for what that covers.

Licensing — the license option

Sqwig has three license states, all fully functional — nothing is ever disabled, and licensing never throws, never phones home:

app.js
createEditor(host, { license: "free" });        // free tier, accepted
createEditor(host, { license: "sqwig-lk1.…" }); // paid entitlements
createEditor(host, {});                          // works, but says so
  • license: "free" — the free tier as a deliberate choice. Setting it constitutes acceptance of the Sqwig Free License (v1 — warranty disclaimer, liability terms, badge condition; no cost, no signup, nothing collected). The footer shows the clean "Powered by Sqwig" badge.
  • A purchased key — your tier's entitlements apply (the badge is removed on paid tiers).
  • Nothing / anything else (including an expired or mangled key) — the editor works fully on free-tier features, the badge reads "Powered by Sqwig · unlicensed", and exactly one console.info explains the state and the fix. Use in this state is governed by the Free License.

Keys are Ed25519-signed tokens verified offline inside the bundle — domain-locked, valid for the term you purchase, and fail-open: any verification problem means the unlicensed state — free-tier features, "· unlicensed" badge marker, a single console note — never a broken editor and never a network call. No per-developer fees, no usage metering. Pricing: sqwig.com/pricing.

How licensing works

Your key is issued for your application's domain at purchase — that's how per-application pricing stays real without any phone-home. At runtime the component checks the signature and the page's hostname against the key, entirely on-device.

Only the hostname is compared. The scheme, the port, and a leading www. are ignored, so a key issued for acme.com matches https://acme.com, http://acme.com, https://www.acme.com and https://acme.com:8443 alike. Tell us your domain; you don't have to work out which canonical form your load balancer redirects to.

  • Domain matches → your tier's entitlements apply (badge off, etc.).
  • Domain doesn't match (or any other verification problem) → the editor still works fully on free-tier features; the badge shows the "· unlicensed" marker until the key matches (or you set license: "free").
  • Subdomains are distinct. www. is the only label ever ignored: app.acme.com and staging.acme.com are different hosts, and evilacme.com is not acme.com. Wildcards (*.acme.com) are how one key covers a whole subdomain tree — and staging/test environments of a licensed app never count as additional applications, so just ask.
  • Development and internal hosts never need a key. The check is skipped entirely — on any port — for localhost and *.localhost, 127.0.0.1 and ::1, the reserved TLDs (.test, .local, .internal, .invalid, .example, .home.arpa), private and link-local IP ranges (10.x, 172.16–31.x, 192.168.x, 169.254.x, IPv6 ULA and link-local), and single-label hostnames such as http://myserver:3000. Develop, run CI, and demo on an intranet without minting anything.
  • Packaged app shells are not a licensed environment. Capacitor, Cordova, Ionic and Electron builds, and browser extensions, run on the Free tier with the badge — a license key will not remove it there. Sqwig v1 targets web applications served from a domain, and a packaged shell has no domain to license against. The editor works normally otherwise; nothing is blocked. If you need Sqwig inside a packaged app, email [email protected] — it is a roadmap question, not a refusal.
  • Keys are term-bound, not version-bound: a key covers every version — upgrade, downgrade or stay where you are — and validates for the term you purchased. After that it stops validating and the component returns to the Free tier, badge and all, on whatever version you're running. Nothing breaks: the editor, spellcheck and paste keep working exactly as before; expiry changes the badge and your entitlements, not what the component does. Renew, or set license: "free".
  • Changed domains or lost your key? Email support — re-issues are free.

Third-party notices

Sqwig bundles a few open-source components (the spellcheck engine, dictionary data, and license-verification crypto). Their notices travel with the artifact automatically — as a banner comment in the built JavaScript, and as THIRD_PARTY_LICENSES.txt at the package root and in dist/ beside the wasm — so no action is needed to stay compliant. If you'd like to surface them in an about or credits screen, they're also exported as a string:

app.js
import { LICENSES } from "@sqwig/core";

Security model

What Sqwig guarantees about its output, and where your application takes over.

Sqwig runs entirely in the browser — no server, no database, no network calls of its own (see Privacy). The surface that matters is the HTML it emits.

The output boundary

getHTML() and the rendered DOM pass through a single whitelist serializer — the only exit path, and an allowlist rather than a blocklist. Only the tags, attributes and styles in the output contract can appear; everything else is dropped or unwrapped.

  • <script>, <style>, <iframe>, event-handler attributes (onerror, onload, …) and unknown elements never survive.
  • href values are scheme-checked at serialization time, so javascript:, data: and vbscript: links can't be smuggled through.
  • Pasted content and initialHTML go through the same whitelist on the way in, so hostile markup never reaches the editable region.

Where your app takes over

Sqwig sanitizes its own markup, not the meaning of your users' text — code blocks are stored verbatim, because that's legitimate writing. Client-side filtering shouldn't be anyone's only boundary, ours included: re-sanitize server-side before stored HTML goes back out, and set a strict CSP.

Found a vulnerability? Email [email protected] with the version and a minimal proof of concept; a SECURITY.md ships in every package. Other questions — sqwig.com/faq.

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.