A writer selects a 400-word draft and the counter says 380. Or a heading and the paragraph under it are counted as one word. Word counts are the kind of feature nobody notices until it is wrong, and then it is the only thing anyone notices. The mistakes almost always come from one of three places: where the text is read from, what counts as a word, and when the count runs.
Start from text, not markup
The quickest route is to read textContent from the editor and split it. For this fragment it gives the wrong answer:
<p>Ship the draft</p><p>by Friday</p>textContentreturns “Ship the draftby Friday”. Two words have fused into one, and “draftby” appears nowhere in what the writer typed. Block elements need a separator, and the cleanest place to add one is a copy of the document rather than the live editor. Parse the HTML into a detached document, drop the parts that are never visible text, and put a space after every block boundary:
function plainText(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("style, script").forEach((node) => node.remove());
doc
.querySelectorAll("p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, tr, td, th, br")
.forEach((el) => el.after(" "));
// Some editors put zero-width spaces around inline widgets. They are not words.
return (doc.body.textContent ?? "").replace(/\u200b/g, "");
}Parsing a copy also keeps the counter from touching the editor. The DOMParser document is never attached to the page, so nothing you do to it can move the cursor or fire an editor event.
What counts as a word
Splitting on whitespace is the obvious rule, and it works for English and most European languages. It breaks for Chinese and Japanese, which do not put spaces between words. “我们今天发布了新版本” is one whitespace-separated token, yet it contains several words. Finding those boundaries needs a dictionary-based segmenter, and modern browsers ship one as Intl.Segmenter:
function countWords(text: string): number {
if (!("Segmenter" in Intl)) {
// Very old browsers: fall back to whitespace, which is close enough for Latin text.
return text.split(/\s+/).filter(Boolean).length;
}
const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
let count = 0;
for (const part of segmenter.segment(text)) {
if (part.isWordLike) count++;
}
return count;
}isWordLikeis true for segments that contain letters or digits, and false for spaces and punctuation. The segmenter follows the Unicode word-boundary rules, so an English contraction such as “don’t” stays one word. A hyphenated compound such as “well-known” becomes two words under these rules. Word itself counts it as one, so if your users compare numbers with Word, pick a rule and write it down rather than hoping nobody checks.
When to run the count
Counting on every keystroke is fine for a short note. For a long document, parsing the whole HTML each time is wasted work, and the count will lag behind the typing. Wait until input settles, for example 200 ms after the last change, and then count. Writers do not watch the number tick up letter by letter, so the delay is invisible to them.
Decide what the number means
A count is a promise to the user, so test it against cases you chose on purpose. Keep a small fixture set and write down the expected answer for each:
- Plain English prose, with a heading and a list.
- German compounds such as “Donaudampfschifffahrt”, which count as one word.
- Chinese and Japanese sentences with no spaces.
- Numbers and dates: “2026”, “3.5”, “1–2”.
- A URL, an email address, and a word surrounded by curly quotes.
- An empty heading and a paragraph that contains only a link.
Characters have the same trap. [...text].length counts code points, so a family emoji made from several joined symbols counts as several characters, while a reader sees one. For a character limit, count grapheme clusters, which is what the reader sees:
function characterCount(text: string): number {
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
let count = 0;
for (const _ of segmenter.segment(text)) count++;
return count;
}The goal is a number your users can predict from the text they see. It does not need to match Word, Google Docs or any other tool exactly, and it is better to say so in the interface than to imply it does.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.