I built a word counter last month while working on a small writing tool, and it turned out to be one of those deceptively simple features. Counting characters is trivial. Counting words correctly — handling double spaces, line breaks, tabs, pasted text from Word — takes a little more care than most tutorials show.
Here is the approach I settled on, with working code you can drop into any page.
The HTML
Nothing fancy: a textarea and a few stat placeholders.
<textarea id="text" rows="8" placeholder="Paste or type your text here..."></textarea>
<div id="stats">
<p>Words: <span id="words">0</span></p>
<p>Characters: <span id="chars">0</span></p>
<p>Characters (no spaces): <span id="charsNoSpaces">0</span></p>
<p>Sentences: <span id="sentences">0</span></p>
<p>Reading time: <span id="readingTime">—</span></p>
</div>
Counting words without the classic bug
The naive approach is text.split(' ').length. That breaks the moment someone types two spaces in a row, hits enter, or pastes content with tabs — every extra whitespace character gets counted as a word.
The fix is to split on any run of whitespace with a regex, then throw away empty strings:
function countWords(str) {
const trimmed = str.trim();
if (trimmed === '') return 0;
return trimmed.split(/\s+/).length;
}
\s+ matches spaces, tabs, newlines, and any combination of them, so "hello world\n\nfoo" correctly counts as 3 words instead of 6. The trim() handles the edge case where the text starts or ends with whitespace, which would otherwise produce a phantom empty entry.
Characters and sentences
Character counts are straightforward, but writers usually want two numbers: with spaces and without. Job applications and publishing platforms often ask for one or the other.
function countChars(str) {
return str.length;
}
function countCharsNoSpaces(str) {
return str.replace(/\s/g, '').length;
}
Sentences are a heuristic — abbreviations like "Mr." will fool any simple splitter — but splitting on sentence-ending punctuation and dropping empties gets you close enough for a writing aid:
function countSentences(str) {
if (str.trim() === '') return 0;
return str
.split(/[.!?]+/)
.map(s => s.trim())
.filter(s => s.length > 0)
.length;
}
Reading time
Most readability research lands around 200–250 words per minute for average adult readers. I use 200 as a conservative default:
function readingTime(wordCount, wpm = 200) {
if (wordCount === 0) return '—';
const minutes = wordCount / wpm;
if (minutes < 1) return 'less than 1 min';
return `${Math.ceil(minutes)} min`;
}
Wiring it up live
Listen to the input event so counts update as the user types or pastes. For a textarea this small, running the counts directly in the handler is fine — no debounce needed unless you are also doing something expensive like syntax highlighting.
const textarea = document.getElementById('text');
textarea.addEventListener('input', () => {
const value = textarea.value;
const words = countWords(value);
document.getElementById('words').textContent = words;
document.getElementById('chars').textContent = countChars(value);
document.getElementById('charsNoSpaces').textContent = countCharsNoSpaces(value);
document.getElementById('sentences').textContent = countSentences(value);
document.getElementById('readingTime').textContent = readingTime(words);
});
One thing worth testing: paste a chunk of text copied from a PDF or a Word document. That is where naive counters fall apart (ligatures, non-breaking spaces, soft hyphens), and where the \s+ split earns its keep. If you want to go further, normalize the input first with str.normalize() and replace non-breaking spaces (\u00A0) with regular ones.
A note on performance: debouncing
For a plain textarea, running the counts on every input event is fine — these are microsecond operations on typical text. But if your counter lives inside a rich editor, or you are also running spellcheck, syntax highlighting, or autosave on each keystroke, wrap the handler in a debounce so it only fires after the user pauses:
function debounce(fn, ms = 150) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
textarea.addEventListener('input', debounce(updateCounts));
150ms is the sweet spot: short enough that the numbers feel live, long enough to skip the work while someone is mid-word.
Handling messy pasted text
The real world pastes ugly text. Content copied from PDFs and Word documents often contains non-breaking spaces (\u00A0), which \s in JavaScript does match — good — but also soft hyphens (\u00AD) and zero-width characters that can glue words together or split them invisibly. If your counter needs to be robust, normalize first:
function normalize(str) {
return str
.normalize() // unify unicode forms
.replace(/[-]/g, '') // strip zero-width chars
.replace(/ /g, ' '); // nbsp -> regular space
}
Run normalize() on the value before counting, and your word count will survive the worst copy-paste jobs your users can throw at it.
When you don't want to build it
This is a fun 30-minute exercise, and I recommend it if you are learning DOM manipulation. But if you just need a quick count for an essay, a product description, or a meta description you are drafting, building your own is overkill — a free online word counter gives you words, characters, sentences, and reading time instantly, with nothing to install.
Top comments (0)