Most editors make grammar checking hard in the same specific way: the API returns offsets computed against a document that the user has already changed by the time the response arrives, and your underlines end up three characters to the left of the mistake. Every integration ends up hand-rolling position bookkeeping to compensate.

CodeMirror 6 solves that problem in the framework. Its RangeSet.map remaps every decoration through a set of document changes, so suggestions follow the text as the user keeps typing — no version guards on the render path, no drift. That single capability makes CM6 arguably the nicest editor to build a checker for, and it's what this guide is built around.

We'll build an extension that proofreads a Markdown document, underlines issues, explains them on hover, applies a fix on click, and skips fenced code blocks. It assumes a working CodeMirror 6 setup. For rich-text frameworks, see the TipTap and ProseMirror integration or the Lexical plugin instead.

The state field that does the work

Suggestions live in a StateField holding a DecorationSet. Its update function does two things: remap existing decorations through whatever the user just typed, and absorb new suggestions when they arrive as a StateEffect.

import { StateEffect, StateField } from "@codemirror/state";
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";

type Suggestion = { start: number; end: number; replacement: string; explanation?: string };

const setSuggestions = StateEffect.define<Suggestion[]>();

const suggestionField = StateField.define<DecorationSet>({
  create: () => Decoration.none,

  update(decorations, tr) {
    // 1. Follow the text. This is the line that makes stale offsets a non-problem.
    decorations = decorations.map(tr.changes);

    // 2. Replace wholesale when a fresh proofread arrives.
    for (const effect of tr.effects) {
      if (!effect.is(setSuggestions)) continue;
      decorations = Decoration.set(
        effect.value.map((s) =>
          Decoration.mark({
            class: "cm-grammar-issue",
            suggestion: s,
          }).range(s.start, s.end),
        ),
        true, // sort
      );
    }
    return decorations;
  },

  provide: (field) => EditorView.decorations.from(field),
});

decorations.map(tr.changes) is the whole trick. Type a word before an underlined error and CodeMirror shifts the decoration by exactly the right amount; delete the underlined text and the decoration disappears on its own. You get correct behavior during the network round trip for free, which in every other editor is bespoke code.

The custom suggestion property on the mark spec is carried through to the decoration, so the tooltip can read it back later without a parallel lookup table.

The proofreading loop

Drive requests from an update listener. Only content changes should trigger a request — cursor movement and selection changes should not.

let version = 0;
let timer: ReturnType<typeof setTimeout> | undefined;

const proofreadListener = EditorView.updateListener.of((update) => {
  if (!update.docChanged) return;

  const requested = ++version;
  clearTimeout(timer);
  timer = setTimeout(async () => {
    const text = update.view.state.doc.toString();
    const result = await amberPen.proofread({
      text,
      dictionary: projectTerms,
    });
    if (requested !== version) return;

    update.view.dispatch({
      effects: setSuggestions.of(filterToProse(result.edits, update.view.state)),
    });
  }, 500);
});

The version counter still earns its place, but for a narrower reason than in other editors: it prevents an older response from overwriting a newer one, not from being positioned wrong. If a response arrives one keystroke late and you dispatch it anyway, map will have already adjusted nothing (the effect carries absolute positions), so the strict guard is the honest choice — discard and let the next debounce fire.

doc.toString() is the exact string the API sees, and every returned offset indexes into it. CodeMirror uses UTF-16 code units for document positions and AmberPen returns UTF-16 offsets, so the two line up with no conversion — including for emoji and astral-plane characters, where naive code-point arithmetic would be off by one per character.

Adding a stable textId turns this into incremental proofreading, which matters as soon as your documents get long. The response still covers the whole document — the SDK carries over the edits it already had for unchanged text — so the wholesale Decoration.set above keeps working unchanged.

Skipping code blocks

Proofreading a Markdown file means proofreading the prose in it, not the fenced Rust snippet. Use the syntax tree to build the set of ranges worth checking, and filter returned edits against them:

import { syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";

const SKIP = new Set(["FencedCode", "CodeText", "InlineCode", "CodeBlock", "URL", "LinkMark"]);

function codeRanges(state: EditorState): Array<[number, number]> {
  const ranges: Array<[number, number]> = [];
  syntaxTree(state).iterate({
    enter: (node) => {
      if (SKIP.has(node.name)) ranges.push([node.from, node.to]);
    },
  });
  return ranges;
}

function filterToProse(edits: Suggestion[], state: EditorState): Suggestion[] {
  const skip = codeRanges(state);
  return edits.filter((e) => !skip.some(([from, to]) => e.start < to && e.end > from));
}

Filtering the response is the cheap version and the right place to start. The thorough version filters the request — send only the prose spans, offset each chunk's returned edits back into document coordinates, and stop paying to proofread code entirely. Do that second, once you know the feature is worth the cost.

Either way, also add the terms your users write constantly to a per-project custom dictionary. A technical writer's document is full of kubectl and WebSocket; without the dictionary field, half your underlines will be on words that were correct.

Hover tooltips

hoverTooltip gives you the explanation UI with no positioning code:

import { hoverTooltip } from "@codemirror/view";

const suggestionTooltip = hoverTooltip((view, pos) => {
  let found: Suggestion | null = null;
  view.state.field(suggestionField).between(pos, pos, (from, to, deco) => {
    found = deco.spec.suggestion;
    return false; // stop at the first hit
  });
  if (!found) return null;

  return {
    pos: found.start,
    end: found.end,
    above: true,
    create: () => {
      const dom = document.createElement("div");
      dom.className = "cm-grammar-tooltip";
      dom.textContent = found!.explanation ?? `Replace with “${found!.replacement}”`;
      const button = dom.appendChild(document.createElement("button"));
      button.textContent = "Fix";
      button.onclick = () => acceptSuggestion(view, found!);
      return { dom };
    },
  };
});

between queries the decoration set by position, which is why storing the suggestion on the mark spec paid off — no separate map to keep in sync with decorations that CodeMirror is silently remapping on your behalf.

The explanation field comes from evaluate mode, which runs a second high-reasoning pass over each proposed edit and returns a plain-language reason, a category, and ranked replacements for the edits it keeps. In correct mode you only get the replacement, which is fine for a "Fix" button and thin for a tooltip.

Applying a correction

An accepted suggestion is an ordinary transaction, so undo, collaborative editing, and every other extension see it as normal typing:

function acceptSuggestion(view: EditorView, suggestion: Suggestion) {
  view.dispatch({
    changes: { from: suggestion.start, to: suggestion.end, insert: suggestion.replacement },
  });
}

Note what you don't have to do: remove the decoration. The change replaces the underlined range, so map drops the decoration that covered it in the same transaction. Positions of every other suggestion shift correctly too, even though the document just got longer or shorter. This is the payoff for putting suggestions in a mapped state field rather than an overlay.

Add the styling and wire it up:

const grammarTheme = EditorView.baseTheme({
  ".cm-grammar-issue": {
    textDecoration: "underline wavy",
    textDecorationColor: "#e7792b",
    textUnderlineOffset: "3px",
  },
});

export const grammarCheck = [suggestionField, proofreadListener, suggestionTooltip, grammarTheme];

One exported array, dropped into your extensions. That's the entire integration.

What to add next

  • Streaming mode — for documents over a few thousand characters, receive complete edit batches as sections finish. Dispatch each batch as its own effect; because the field maps on every transaction, batches arriving over several seconds all stay correctly positioned.
  • Accept-all and dismissview.dispatch({ changes: [...] }) accepts a list of non-overlapping edits in one undoable transaction, which is exactly what the API returns.
  • A dismissed-suggestion set — key on the replaced text and category so a rejected suggestion doesn't reappear on the next request.

Why this editor is the easy one

Grammar checking in a text editor is fundamentally a position-tracking problem wearing a language-model costume. CodeMirror 6 already solved position tracking — mapped range sets are how it implements search highlighting, linting, and collaborative cursors — so a checker gets to be what it should have been all along: fetch edits, mark ranges, dispatch a change on accept.

The remaining variable is whether the edits are worth showing. That's a question about the API, not the editor, and it's measurable: how grammar correction is measured explains what vendor benchmark numbers do and don't prove, and a free test key lets you run your own documents through and judge the underlines yourself.