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

Install @sqwig/core, copy the three runtime assets into a folder your app serves, and call createEditor with a license. No bundler required: a plain ES-module page is enough.

Full quickstart →: install, assets, the license option, and what to check when the underlines do not appear.

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

Custom dictionary (paid plans, from 1.2.0)

The dictionary knows English; it does not know your product. The customWords option ships your application's own vocabulary so it is correct for every user, on every device, with no per-user setup:

app.js
createEditor(host, {
  license: key,
  spell,
  customWords: ["Sqwig", "Frontline", "SSO"],
});
  • Plan capacity: Starter honors 100 words, Enterprise 1,000. The first entries in array order count; anything past the capacity stays inactive, with a single console note saying so. Need more? hello@sqwig.com.
  • Configuration, not storage: the words are supplied on every load and never written to the device. Each user's own Add to dictionary words layer on top.
  • Matching is case-insensitive, entries are single words (whitespace-containing entries are skipped), duplicates count once, and near-misses suggest your words (Sqwgi suggests Sqwig).
  • On the Free tier the option is accepted but inactive, with one console note. Nothing throws at any tier.

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.
--- on its own line, then EnterA horizontal rule (<hr>).

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.

Text color and highlight (every tier, from 1.9.0)

Two Word-style split buttons in the toolbar: the main half applies the current color in one click, the chevron opens the palette. Font color offers Automatic, a color grid and a hex field; the highlighter carries Word's 15 highlighter colors, and Export to Word maps each one to the same-named Word highlighter color.

These shipped as paid controls in 1.4.0 and moved to every tier in 1.9.0. Color has always been content rather than chrome: colored text arriving via paste (pasteColors) or setHTML rendered on every tier even then, and applied colors survive license expiry.

Fullscreen, horizontal rule & list numbering

Three toolbar controls that work the way Word taught everyone to expect. All tiers, from 1.5.0.

Fullscreen

The expand button at the end of the toolbar grows the editor to fill the window; pressing it again, or Escape, returns it to the page. It is a view change only: the document, getHTML() and undo history are untouched. If a popover or menu is open, Escape closes that first, then exits fullscreen.

Horizontal rule

The rule button inserts a divider, emitted as a plain <hr>. Typing --- on its own line and pressing Enter inserts one too; only Enter completes it, so --- followed by anything else stays literal text and prose is not converted mid-sentence. Pasted rules survive as rules, and Export to Word writes the same bottom-border paragraph Word itself uses for a typed rule. Rules are not inserted inside code blocks.

Numbered-list start

Below the numbering styles, the numbered-list menu offers Restart at 1, Continue numbering (carries on from the previous list, for when a paragraph interrupts one), and Set numbering value… for an exact start. The value is stored as the standard start attribute on the <ol>, so it renders anywhere HTML renders and exports to Word as real numbering.

Find & replace

A docked bar under the toolbar: search, step through matches, replace one or all. All tiers, from 1.5.0.

Press Ctrl/Cmd+F with the caret in the editor, or use the toolbar's Find & replace button, to open the find row. The bar shows a live match count, the chevrons step through matches with wraparound, and Aa toggles match case. Ctrl/Cmd+H, or the expand control at the start of the bar, adds the replace row. The shortcuts take over the browser's own find only while focus is inside the editor; everywhere else on your page they behave as usual. Escape closes the bar and returns focus to the text.

Replace swaps the current match and advances; Replace All rewrites every match as a single step, so one Ctrl/Cmd+Z puts the whole document back. Replacements pick up the formatting at the match. Code blocks are searched: find is not spellcheck, and useEffect should be findable. In read-only mode, search works and the replace controls are disabled.

Keyboard shortcuts

Every key Sqwig binds, and the ones it deliberately leaves alone.

KeyDoes
Ctrl/Cmd+B Ctrl/Cmd+I Ctrl/Cmd+UBold, italic, underline
Ctrl/Cmd+KInsert or edit a link
Ctrl/Cmd+F Ctrl/Cmd+HFind, and find with the replace row already open
Ctrl/Cmd+Z Ctrl/Cmd+YUndo, redo (Ctrl/Cmd+Shift+Z also redoes)
Ctrl/Cmd+M Ctrl/Cmd+Shift+MIncrease, decrease indent
Shift+F3Cycle case: lower, UPPER, Title
Ctrl/Cmd+, Ctrl/Cmd+.Subscript, superscript
Ctrl/Cmd+\Clear all formatting
Ctrl/Cmd+Shift+VPaste as plain text
Tab Shift+TabMove between table cells
EscapeClose an open popover or menu, then leave fullscreen

Sqwig does not take keys your browser already owns. Ctrl/Cmd+R still reloads, Ctrl/Cmd+= still zooms, Ctrl/Cmd+L still reaches the address bar. Alignment has no shortcut for that reason: the four keys people reach for, Ctrl+L, Ctrl+E, Ctrl+R and Ctrl+J, are all taken by the browser. The toolbar buttons are always there instead.

Shortcuts are bound to commands, not to buttons, so trimming the toolbar does not remove them. The exceptions are Ctrl/Cmd+K and Ctrl/Cmd+F: both open a surface anchored to their toolbar button, so with that button configured away they do nothing.

Every shortcut is available on all tiers, and each one is a single undo step.

Tables

Build and reshape tables (from 1.3.0).

The toolbar's Table control opens a size picker outside a table and the full table menu inside one. When the caret is in a table, a balloon appears above it with Insert, Delete and Style menus. Tab / Shift+Tab move between cells; Tab in the last cell adds a row. Every operation is a single Ctrl/Cmd+Z step.

Structure is included on every tier: row and column inserts in all four directions, deletes, and a header-row toggle that produces real <th> cells.

Table styles, sort & convert (paid plans, from 1.3.0)

Two presets, Banded rows and Header tinted, applied as a single data-sqwig-style attribute on the <table>, never inline colors: your stylesheet can restyle or ignore it, and Export to Word maps each preset to a built-in Word table style. Sort rows A→Z / Z→A by the caret's column (a header row stays pinned). Convert turns tab-separated paragraphs into a table and back.

Header tinted also marks the first row as a real header row, so it repeats across page breaks in Word. Because each preset maps to a built-in Word table style, the exported table takes its colors from the Word document's theme rather than from your editor theme: if you have restyled --sqwig-accent, the two will not match, and the table stays a native Word style the recipient can change.

Applied styles are content. They survive license expiry; only the controls follow the plan.

Images

Insert by URL or file pick, paste from Word, alt text, delete. Your app hosts the images; the document stores only URLs. All tiers, from 1.5.0.

The toolbar's Image button inserts an image by URL on every setup. Give the editor an imageUpload handler and it also offers a file picker, and routes pasted images through your app:

app.js
createEditor(host, {
  license: key,
  imageUpload: async (file) => {
    const res = await fetch("/api/uploads", { method: "POST", body: file.bytes });
    return (await res.json()).url;  // an http(s) URL your app serves
  },
});
  • The handler is the upload. It receives the image bytes ({ bytes, type, name }) and resolves to the URL your app will serve them from. Sqwig hosts nothing and uploads nothing itself; image bytes flow only where your handler sends them.
  • Paste: an image that arrives with an http(s) address is kept as is. An image that arrives as embedded data (a Word paste, for example) is handed to your handler, shows an uploading shimmer, and swaps to the returned URL when it resolves. With no handler configured, those images fall back to an [image] placeholder, with one console note saying why.
  • If an upload fails: the placeholder stays in place, marked "Upload failed", and clicking it offers Retry and Remove. Retry re-sends the same bytes, so the person typing does not have to find the file again. Placeholders are editor state, not content: getHTML() never contains one.
  • Alt text and delete: click an image to edit its alt text or remove it. Every emitted image carries an alt attribute, empty when unset.
  • Clean output: getHTML() emits <img> with src, alt and pixel width/height only. The src is validated to http(s) at entry and exit, so the output contains only the URLs your app supplied: Sqwig does not embed image data, and a data: image cannot appear in the document or any export.
  • Word export: Export to Word writes each image as a linked picture pointing at its URL, so the export still fetches nothing and stays entirely in the browser.
  • Undo: one Ctrl/Cmd+Z removes an inserted image, or a whole paste including its images. Images count zero characters toward maxLength and zero words in the word count.
Spellcheck is unchanged. Spellcheck and all text processing stay fully client-side, and the component still makes zero calls to Sqwig servers. An image renders the way any image in your app does: the browser fetches the URL your app supplied, from your app's own hosting.

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: arrive as real tables (declared header cells kept as <th>, ragged shapes squared up) and can be reshaped with table authoring. Block commands stay disabled inside cells; pastes into a cell insert as text.
  • Images: http(s) images are kept; embedded images route through your imageUpload handler, or become an [image] placeholder without one. Details: Images.

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.

Markdown

Opt-in conversion on the way in, getMarkdown() on the way out. All tiers, from 1.5.0.

The pasteMarkdown option

Off by default, and the default is byte-for-byte today's behavior. With pasteMarkdown: true, plain-text pastes are read as Markdown: headings, lists (a numbered list keeps its start value), blockquotes, fenced code blocks, bold, italic, strikethrough, inline code, links, images, GFM tables and --- rules all convert. Text without Markdown syntax comes out identical to a normal plain paste, and nothing is detected or guessed: the option is the only switch. Rich pastes from Word or Docs are unaffected, and Ctrl/Cmd+Shift+V still pastes literally. A conversion that fails falls back to the normal plain paste, so a failure costs the conversion, not the paste.

The cost, plainly: the clipboard carries the same plain text for a .md file and a .txt file, so the editor cannot tell them apart. With the option on, any pasted plain text whose lines happen to start with # or - converts too. If your users paste plain text that only looks like Markdown, leave the option off.

getMarkdown()

editor.getMarkdown() returns the document as GitHub-flavored Markdown. Structure and text always survive. Three inline styles Markdown has no syntax for, underline, subscript and superscript, ride as inline HTML (<u>, <sub>, <sup>), which GFM renders.

What drops: Markdown has no vocabulary for visual styling, so font family and size, text color and highlight, alignment, line spacing, indentation, list style variants and table style presets are not carried; their text is. A document that uses only Markdown-expressible formatting round-trips through pasteMarkdown unchanged. getHTML() is unaffected.

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

Export to Word and PDF

One click downloads the document as a real Word file or a real PDF, both assembled entirely in the user's browser. Paid-plan features.

Headings, lists, links, tables, sizes and colors arrive as native Word constructs: real Heading styles (the navigation pane works), real numbering, live hyperlinks, and from 1.3.0, header rows and table style presets as real Word table styles.

The PDF is paginated here rather than by a print dialog, so you get a file, not a prompt. Its text is real text: selectable, searchable, and extractable by the resume parsers an applicant tracking system runs. It carries a structure tree as well, so a screen reader gets headings, lists and tables instead of one flat block, and the page-number footer is marked as furniture so it is not read out as content.

Nothing leaves the machine for either format: there is no conversion service.

app.js
const docx = await editor.exportDocx(); // Promise<Blob | null>
const pdf  = await editor.exportPdf();

// Both take the same options, and every field is optional.
await editor.exportPdf({
  pageSize: "a4",        // "letter" (default) or "a4"
  pageNumbers: false,   // "Page N of M" footer, on by default
  baseUrl: "https://acme.com/",   // resolves relative links
  onExportReport: (r) => {          // what the export could not do
    if (r.unsupported.length) warnUser(r.unsupported);
  },
});

Links, and why baseUrl has no default

<a href="/docs"> works on the page it was written on. An exported file has no base URL, so once it has been mailed to someone that link reaches nothing. Give us baseUrl, the public home of the content, and relative links resolve against it in both formats. Absolute links are never touched: a LinkedIn URL on a résumé, a mailto:, anything carrying a scheme arrives exactly as written.

There is deliberately no default. The obvious one, the editor's own address, is wrong almost always: an editor running on localhost or inside an applicant tracking system has nothing to do with where the content publishes, and resolving against it would turn a visibly dead link into a plausible, wrong one inside someone's document. Only your app knows. Without it, relative links are written exactly as authored and listed in unresolvedLinks on the report.

The export report

onExportReport is called once, after the file exists, with what the export could not do: unsupported characters, rtl text, unresolvedLinks, per-image outcomes, which supplied fonts embedded, and the page count. Counts and characters only, never your document's text. Throwing from your handler cannot break an export, and it runs after the bytes exist, so a slow one cannot stall a download. PDF only today.

Two things the two formats do differently

Images. The Word file links each image by URL and fetches nothing. A PDF has no such mechanism, so the bytes have to be embedded: exporting a PDF fetches each image from your own image host. An image the browser cannot read (a cross-origin URL with no CORS headers) becomes a bordered placeholder carrying its alt text. An export never fails because an image did.

Fonts. The PDF uses the standard fonts every reader already has, which cover Western European text. A name like Łukasz or Dvořák is outside that set and renders as ?, reported on the console rather than silently mangled. To include it, hand us the font:

app.js
await editor.exportPdf({
  fonts: {
    name: "Inter",
    regular: interRegularBytes,  // Uint8Array of a .ttf you have the rights to
    bold: interBoldBytes,        // optional; falls back to regular
  },
});

We subset it to the characters the document actually uses, so a 2MB font typically adds around 200KB to the file. TrueType only, and it applies to the PDF: Word embeds fonts by its own mechanism and the reader usually has the font installed anyway.

Right-to-left text is not laid out right-to-left. Hebrew and Arabic are reported on the console so you can warn the user, rather than arriving reversed and unjoined in a document somebody forwards.

With a valid paid key both buttons appear at the end of the toolbar; each is a separate toolbar item (export-word and export-pdf), so you can show either, both or neither. On the Free tier the buttons are not shown and both methods resolve null (one console note explains; nothing throws). With a key present but expired, the buttons appear disabled; renewing re-enables them.

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.

React integration guide →: props, refs, Vite and Next.js setup, and the pitfalls worth knowing before you ship.

Vue

@sqwig/vue exports <SqwigEditor> for Vue 3: a thin, uncontrolled wrapper. One options prop (the same object createEditor takes, read at mount); @change / @selection-change / @spell-state forward the core events; a template ref exposes the full core Editor.

Vue integration guide →: the options prop, emits, template refs, Vite and Nuxt setup, and why there is no v-model.

Angular

@sqwig/angular exports <sqwig-editor> for Angular 15+: a thin, uncontrolled standalone component. One options input (the same object createEditor takes, read at mount); (change) / (selectionChange) / (spellState) forward the core events; @ViewChild exposes the full core Editor.

Angular integration guide →: the options input, outputs, editor access, asset serving, and why there is no ngModel.

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.10.4.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
license"free" | string (required)"free" accepts the Sqwig Free License; a purchased key applies per its entitlements; anything else runs unlicensed with the badge. Licensing never throws and never gates the editor. Details: licensing.
placeholderstringGhost text when empty; also the region's accessible label.
initialHTMLstringSeeds the document (sanitized through the whitelist on the way in).
toolbarboolean | "fixed" | "floating" | ids | { mode, items } = 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). From 1.4.0 the option also picks the buttons: an array of ids in your order (toolbar: ["undo", "redo", "|", "bold", "italic", "link"]), a preset ({ items: "full" } or "minimal"), or { mode, items } to combine either with the mode. Ids are the data-sqwig-control value on each button; the presets are exported as TOOLBAR_PRESETS for composing; unknown ids are ignored with one console note.
trustIndicatorboolean | { text?, wordCount? } = trueFooter with the privacy note; false hides it (a paid-plan option from 1.1.0; on the Free tier the footer stays and one console note says so), 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.
customWordsstring[]Your application's dictionary: words that are correct for every user (paid plans, up to the plan's capacity). Session-scoped, never stored on the device. Details: custom dictionary.
mode"live" | "manual" = "live"Live squiggles, or sweep on demand via checkNow().
pasteFontSizes"off" | "relative" | "absolute" = "off"Recover declared font sizes from rich pastes (all tiers). "relative" preserves the source document's own size hierarchy (recommended); "absolute" keeps literal point sizes. Off = sizes strip to the clean default, as always.
pasteColorsboolean = falseRecover declared text color from rich pastes as clean #rrggbb (all tiers). The default ink never carries over, so black-text documents paste exactly as before.
pasteMarkdownboolean = falseRead plain-text pastes as Markdown (all tiers). Off, the default, is exactly today's paste. On, a paste with no Markdown syntax is unchanged, and a .txt paste whose lines start with # or - converts too: the clipboard cannot tell the two apart. Details: Markdown.
imageUpload(file) => Promise<string>Your app's image upload (all tiers): receives picked or pasted image bytes, resolves to the http(s) URL your app serves them from. Enables the file picker and pasted-image routing; without it those fall back to an [image] placeholder. Details: Images.
readOnlyboolean = falseStart read-only (toggle later with setReadOnly).
maxLengthnumberHard character cap: typing blocked past it, pastes truncated, counter warns as it fills.
maxHeightnumberCap the editor's height in pixels. Past the cap the text scrolls inside the box instead of growing and pushing your page down. Applies on every device, with or without the drag handle. Unset, the editor grows with its content, exactly as it did before this option existed.
resizableboolean = falseShow a drag handle on the bottom edge so people can set the height themselves, by pointer or with the arrow keys. Desktop only: touch devices never show it, because a bar thin enough to look right is too small to hit reliably. maxHeight still applies there.
heightnumberStarting height in pixels, for restoring a height you saved yourself. Clamped to a usable minimum, so a stale or bad value cannot produce an editor nobody can type in.
onResize(height) => voidCalled with the new height in pixels once a resize settles, not on every frame of a drag. Sqwig stores nothing on the device, so persist the value yourself and pass it back as height if you want it to survive a reload.
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.
getMarkdown()The document as GitHub-flavored Markdown, synchronously. Underline, sub and sup ride as inline HTML; visual styling drops. Detail: Markdown.
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).
exportDocx(options?)The document as a Word file: Promise<Blob | null>, assembled in the browser (paid plans; null on the Free tier with one console note, never throws). Detail: Export.
exportPdf(options?)The document as a paginated, tagged PDF: Promise<Blob | null>, same gate and same contract. Options: pageSize, pageNumbers, fonts, baseUrl, onExportReport. Detail: Export.
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/color/background-color], ul, ol[start], li, a[href], img[src, alt, width, height], hr, br, table, tr, td, pre, blockquote, with block text-align/line-height/margin-left, list list-style-type, an optional integer start on numbered lists, and scheme-checked hrefs. Image src attributes are http(s) URLs only: the document carries no embedded image data. 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, and all three run the full core editor: licensing never throws, never phones home. Paid plans additionally enable the features we designate as paid:

app.js
createEditor(host, { license: "free" });        // free tier, accepted
createEditor(host, { license: "sqwig-lk1.…" }); // paid entitlements
createEditor(host, {});                          // works, but says so
  • license: "free" is the free tier as a deliberate choice. Setting it constitutes acceptance of the Sqwig Free License v1.1: development, evaluation and non-commercial production, with a warranty disclaimer, liability terms and the badge condition. A commercial application in production needs a paid plan. 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. 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 hello@sqwig.com: 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, and your content is untouched. Expiry changes your entitlements: the badge returns and features designated as paid revert to their Free-tier behavior. 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 against an allowlist: http:, https:, mailto:, tel:, sms: and relative URLs survive. Everything else is dropped, so javascript:, data: and vbscript: links can't be smuggled through, obfuscated or not. Image src is stricter still: http: and https: only.
  • 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 security@sqwig.com 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.

Changelog

One line per release; the full entries ship inside both packages and the download zip.

1.10.4

A Word paste could leave a stray line break in getHTML() output. Browsers collapsed it on screen, so only the saved payload carried it.

1.10.3

Documentation only. Each package README now links to the 30-day trial of the paid features, and states the Free tier's scope: development, evaluation and non-commercial production. No license change.

1.10.2

Documentation only. Links in each package README now read as titles rather than showing the full web address.

1.10.1

Documentation only. Updated the sqwig.com links in each package README.

1.10.0

Export to PDF on paid plans, written in the browser with real selectable text and a structure tree for screen readers. Both exports take options for page size, page numbers, link resolution and font embedding. tel: and sms: links are now kept.

1.9.0

Text color and highlight move from paid plans to every tier, including Free.

1.8.1

Sharper menus, and each formatting button drawn in the style it applies. On every tier.

1.8.0

Upgraded toolbar icons, plus keyboard shortcuts for indent, change case, subscript, superscript and clear formatting. On every tier.

1.7.1

Undo now fires one change event instead of two. Smaller bundle. On every tier.

1.7.0

Set the editor's height with maxHeight, so a long paste scrolls inside the box instead of pushing your page down, or add a drag handle with resizable. On every tier.

1.6.1

Bug fixes and improved Undo performance. On every tier.

1.6.0

Undo rebuilt: one Ctrl+Z restores the document exactly for every command and position, redo replays it exactly, and typing undoes in word bursts the way Word does. On every tier.

1.5.1

Fixed undo across all toolbar commands: the document now restores exactly. Lists no longer lose text when applied to a document's leading heading, or to a quoted paragraph. getText() now separates blocks, and the word count no longer merges words across block boundaries. All on every tier.

1.5.0

Find & replace, images with an imageUpload handler, and markdown in and out, plus fullscreen, horizontal rule and list numbering controls. All on every tier. Code block line-break and undo fixes.

1.4.0

Text color and highlight (paid plans at the time; free on every tier from 1.9.0), with Export to Word mapping each highlighter color to its Word equivalent. Per-button toolbar configuration on every tier. New official Angular wrapper, @sqwig/angular (integration guide).

1.3.2

Table header rows keep their header styling when exported to Word, and repeat across page breaks. Header tinted now marks the first row as a real header row.

1.3.1

Chrome polish across the editor: the suggestion popover, the list and table panels, and link editing now share one icon language and one way of marking the current selection.

1.3.0

Table authoring on every tier: inserts, deletes, header rows, a contextual balloon. Table styles, sort and convert (paid plans). Paste keeps declared <th> header cells.

1.2.0

Custom dictionaries: the customWords option ships your application's own vocabulary (paid plans). New official Vue 3 wrapper, @sqwig/vue (integration guide).

1.1.0

Export to Word (paid plans). New opt-in paste options (pasteFontSizes and pasteColors) on every tier. Hiding the trust footer becomes a paid-plan option. Fixed an inline formatting bug.

1.0.2

Spellcheck no longer flags possessive forms (device's) as misspellings.

1.0.1

Atomic undo/redo for every toolbar action and accepted suggestion.

1.0.0

First public release.