Angular rich text editor with built-in spellcheck
@sqwig/angular gives you a rich text editor whose spellcheck actually works: red underlines, suggestions on click, running entirely in the browser. No API key, no per-seat pricing, and no text leaving the page.
Why this is unusual
Most Angular rich text editors treat spellcheck as somebody else's job. You get formatting, lists and links, and then you either accept the browser's native checker, which you cannot style, position or control, and which disappears the moment you set contenteditable to a custom implementation, or you bolt on a paid service that sends every keystroke to someone's server.
Sqwig ships the dictionary. A SymSpell engine compiled to WebAssembly runs in a Web Worker beside your editor, checks as the user types, and renders its own underlines and suggestion popover. Nothing is transmitted, so there is no request to fail, no latency to hide, and nothing to put in a DPA.
Install
npm i @sqwig/core @sqwig/angular
Requires Angular 15 or newer: the wrapper is a standalone component, and there is no NgModule.
Then serve the three runtime assets: the WASM engine, its worker, and the dictionary chunks. A bundler cannot inline them because they are fetched at runtime. With the Angular CLI, copy them into public/sqwig/ (src/assets/sqwig/ on Angular 15 and 16, which predate public/), or publish them straight from node_modules via angular.json (see serving the assets). Full detail in the quickstart.
Basic usage
<sqwig-editor> is a thin, uncontrolled wrapper. It takes a single required options input, the same object createEditor takes, read once at mount.
import { Component, ViewChild } from "@angular/core";
import { SqwigEditorComponent } from "@sqwig/angular";
import { createSpellChecker } from "@sqwig/core";
@Component({
selector: "app-editor",
standalone: true,
imports: [SqwigEditorComponent],
template: `
<sqwig-editor [options]="options" (change)="save($event)"></sqwig-editor>
`,
})
export class EditorComponent {
// Create ONCE, as a class field. The object is read once, at mount,
// and the spellchecker owns a Web Worker and a WASM instance.
readonly options = {
license: "free",
spell: createSpellChecker({ assetBaseUrl: "/sqwig/" }),
placeholder: "Say something…",
};
@ViewChild(SqwigEditorComponent) sqwig?: SqwigEditorComponent;
save(html: string) { /* your persistence */ }
}
Declaring options as a class field is the single most important line on this page. It is created once per component instance, so the spellchecker, with its Worker and WASM engine, is created once. A getter that builds a fresh object would run on every change-detection cycle.
One options input, three outputs
Options do not travel as individual inputs: a per-field @Input() surface would be a hand-maintained mirror of EditorOptions that drifts every time the core adds an option. One object avoids the drift and matches createEditor exactly.
| Input | Type | Notes |
|---|---|---|
options | EditorOptions | Required. Read once at mount. license is required inside it; see the license option. Every field is in the full options table. |
class and style need no inputs at all; Angular puts them on the component's host element natively.
| Output | Payload | Notes |
|---|---|---|
(change) | html: string | Fires on every content change, with the serialized HTML. |
(selectionChange) | (none) | Selection moved. Read state off the exposed editor. |
(spellState) | state: SpellState | Spellchecker readiness changed; useful for a loading affordance. |
The editor mounts outside the Angular zone, so its keystroke handling and spellcheck passes do not trigger app-wide change detection. Output emissions re-enter the zone, so your bindings update normally under default change detection, and the component also works under zoneless bootstrap.
@ViewChild exposes the editor
The component's public editor field is the complete core Editor instance: not a subset, and not an Angular-flavoured shim. Anything the core can do, you can do.
@ViewChild(SqwigEditorComponent) sqwig?: SqwigEditorComponent;
// later
this.sqwig?.editor?.getHTML();
this.sqwig?.editor?.setHTML("<p>Replaced</p>");
this.sqwig?.editor?.focus();
editor is null before ngAfterViewInit and after destroy. See editor methods and events.
Loading saved content
options.initialHTML is read once, at mount. Content from a request usually arrives after the editor has mounted, and by then a new initialHTML is ignored: the editor stays empty, and the next save writes that empty document over the stored one. The component logs a console warning when this happens.
Build the options when the data arrives, and render the editor only once they exist:
import { createSpellChecker, type EditorOptions } from "@sqwig/core";
template: `
@if (options) {
<sqwig-editor [options]="options" (change)="save($event)"></sqwig-editor>
} @else {
<p>Loading…</p>
}
`,
// in the class:
readonly spell = createSpellChecker({ assetBaseUrl: "/sqwig/" });
options?: EditorOptions;
ngOnInit() {
this.http.get<Posting>(`/api/postings/${this.id}`).subscribe((p) => {
this.options = {
license: "free",
spell: this.spell,
initialHTML: p.descriptionHtml,
};
});
}
On Angular 15 or 16, use *ngIf="options" in place of @if. The spellchecker stays a class field so it is created once. Behind @if or *ngIf the editor mounts after your component's ngAfterViewInit, so read this.sqwig?.editor at the moment you need it, in a click handler for example. To replace content in an editor that is already on screen, such as a Revert button, call this.sqwig?.editor?.setHTML(html) through @ViewChild.
Whether setHTML fires change, whether undo can remove a load, what an empty editor returns, and sharing one spellchecker: saving & loading.
Displaying saved content
Sqwig's output carries alignment, color, font and spacing as inline style attributes (see the output contract). Angular's default [innerHTML] sanitizer removes every style attribute, so a saved posting shown that way loses its centered lines and colors.
Re-sanitize on your server before stored HTML goes back out, as the security model asks, with an allowlist that keeps the tags and styles in the output contract. Then mark that HTML as trusted when you display it:
import { Pipe, inject, type PipeTransform } from "@angular/core";
import { DomSanitizer, type SafeHtml } from "@angular/platform-browser";
// Only for HTML your server has already sanitized.
@Pipe({ name: "trustedHtml", standalone: true })
export class TrustedHtmlPipe implements PipeTransform {
private readonly sanitizer = inject(DomSanitizer);
transform(html: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(html);
}
}
<article [innerHTML]="posting.descriptionHtml | trustedHtml"></article>
Formatting travels as inline styles and the output uses no class names, so it displays correctly without Sqwig's stylesheet. The one exception is a styled table, which carries data-sqwig-style="banded" or "tinted": style it with your own CSS, or leave it plain.
Export to Word and PDF
Paid plans. The toolbar buttons need no code. For your own button, call the method on the component's editor and save the Blob it returns with a copy of the downloadBlob function from the export section (a few lines of your own code, not a Sqwig export):
@ViewChild(SqwigEditorComponent) sqwig?: SqwigEditorComponent;
readonly message = signal("");
async exportWord() {
const blob = await this.sqwig?.editor?.exportDocx();
if (blob) downloadBlob(blob, "job-posting.docx");
else this.message.set("Export is not available right now.");
}
<!-- template -->
<button (click)="exportWord()">Export to Word</button>
Write the result to a signal, as here. In a zoneless app, change detection runs when a signal changes, not when a promise resolves, so a plain field set after the await would not show. On Angular 15, which has no signals, use a plain field: zone.js runs change detection when the promise resolves. exportPdf() works the same way. Both resolve null on the Free tier or if the export fails, and never throw.
Why there is no ngModel
Deliberately. A two-way bound rich text editor has to reconcile a framework render against a live DOM selection on every keystroke, and the usual result is a cursor that jumps to the end of the document mid-word. A ControlValueAccessor would also imply forms integration, validators, and touched/dirty semantics the wrapper does not honor. Sqwig keeps the document in the editor and hands you HTML through (change).
In practice: hold the HTML wherever you need it for a save button, but do not feed it back in. To replace content programmatically, call setHTML on the exposed editor. One content model, same as the React and Vue wrappers.
Serving the assets with the Angular CLI
Either route works; pick one.
Copy into public/ (src/assets/ on Angular 15 and 16) with the quickstart's copy script, run in front of serve and build, and point at it with a root-relative path:
"start": "node scripts/copy-sqwig.mjs && ng serve",
"build": "node scripts/copy-sqwig.mjs && ng build"
Or publish straight from node_modules, with no copy step and nothing to gitignore. The files stay current with the installed package:
"assets": [
{ "glob": "spell_wasm_bg.wasm", "input": "node_modules/@sqwig/core/dist", "output": "sqwig" },
{ "glob": "spell.worker.js", "input": "node_modules/@sqwig/core/dist", "output": "sqwig" },
{ "glob": "dict/*", "input": "node_modules/@sqwig/core/dist", "output": "sqwig" },
{ "glob": "THIRD_PARTY_LICENSES.txt", "input": "node_modules/@sqwig/core/dist", "output": "sqwig" }
]
Do not import the worker or the .wasm. The build would try to fingerprint and rewrite them, and the runtime fetch then misses. They are served files, not module graph members.
Angular Universal and server rendering
The editor needs a real DOM, a Web Worker and WebAssembly, so it is client-only. The component's template is an empty host element, and it mounts the editor in ngAfterViewInit, which does not run during server rendering. No platform guard needed.
Common pitfalls
Options built by a getter
get options() { return { license: "free", spell: createSpellChecker() }; } runs on every change-detection cycle and rebuilds the object, spellchecker, Worker and all. Declare it once, as a class field, as above.
Changing options and nothing happening
The options object is read once, at mount. Mutating it later, or binding a new object, will not re-initialise the editor. Use the exposed editor for runtime changes, or force a remount with *ngIf if you genuinely need fresh options.
Underlines never appear
The editor mounted but the assets 404'd. Check the Network panel for spell.worker.js, spell_wasm_bg.wasm and dict/. The editor deliberately keeps working without them, so this fails quietly.
Reading the editor too early
this.sqwig?.editor is null until ngAfterViewInit has run. Read it from lifecycle hooks at or after that point, or from event handlers, not from the constructor or ngOnInit.
Editors that open and close
A spellchecker created inside a component is rebuilt each time that component mounts, so a dialog or route that shows the editor again reloads the engine and dictionary. Create it once for the app, in a service or a module-level constant, and pass the same checker to every editor. A checker your app no longer references is released automatically, so per-component checkers do not pile up; they only cost the reload.
Where to go next
- Quickstart: installation, assets, the license option
- createEditor options: the full table
- Styling and theming: CSS custom properties
- Spellcheck and suggestions: how checking behaves
- Privacy: what the component transmits, which is nothing