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.