I used to think a word counter was the most boring tool imaginable. Split on spaces, maybe trim a newline, done.
That illusion lasted right up until I had to handle Chinese text, mixed Chinese+English text, and emoji in the same input box. Once you do that, even the question "what counts as one character?" stops being obvious.
"Word count" breaks the moment spaces stop meaning words
The core decision in this tool is that it doesn't treat every language like English. Instead of assuming whitespace-separated words are the main unit, it checks how much of the input is CJK and switches the highlighted metric when that ratio gets high enough:
const CJK_RANGE =
/[⺀--ヿ㐀-䶿一-鿿豈-가-힣]/;
const cjkCharCount = computed(
() => graphemes.value.filter((ch) => CJK_RANGE.test(ch)).length,
);
const isCjkDominant = computed(() => {
const nonSpace = charCountNoSpaces.value;
if (nonSpace === 0) return false;
return cjkCharCount.value / nonSpace >= 0.35;
});
const wordCount = computed(() => {
const trimmed = (inputText.value || "").trim();
if (!trimmed) return 0;
return trimmed.split(/\s+/).filter(Boolean).length;
});
That 0.35 threshold is the interesting part. The tool is not trying to do true linguistic segmentation. It's making a practical UI decision: if enough of the non-space characters are CJK, then "characters" is probably the more honest primary unit than "words."
That's also why mixed text behaves reasonably. A sentence like 今天 shipping fix to prod still shows both numbers, but the tool decides which one deserves the visual emphasis instead of pretending one rule fits every script.
It avoids the classic JavaScript .length trap — mostly
If this were using plain string .length, emoji and other non-BMP characters would get overcounted because JavaScript strings are UTF-16 under the hood. The component explicitly avoids that:
const graphemes = computed(() => Array.from(inputText.value || ""));
const charCountWithSpaces = computed(() => graphemes.value.length);
const charCountNoSpaces = computed(
() => graphemes.value.filter((ch) => !/\s/.test(ch)).length,
);
Array.from() iterates by Unicode code point, which is already much better than counting UTF-16 code units. A single 😀 won't accidentally become 2 characters.
I also like that the no-space count is derived from the same array instead of from a second regex-heavy pass over the raw string. It keeps the counting model consistent: first normalize into countable units, then filter those units.
Reading time, sentences, and paragraphs are all heuristics too
The tool doesn't just count text units. It also changes the reading-time formula depending on the detected script, and it uses lightweight regex rules for sentences and paragraphs:
const CJK_CHARS_PER_MINUTE = 300;
const WORDS_PER_MINUTE = 200;
const readingTimeSeconds = computed(() => {
if (isCjkDominant.value) {
return Math.ceil((charCountNoSpaces.value / CJK_CHARS_PER_MINUTE) * 60);
}
return Math.ceil((wordCount.value / WORDS_PER_MINUTE) * 60);
});
const sentenceCount = computed(() => {
const trimmed = (inputText.value || "").trim();
if (!trimmed) return 0;
return trimmed
.split(/[.!?。!?…]+/)
.map((s) => s.trim())
.filter(Boolean).length;
});
const paragraphCount = computed(() => {
const text = inputText.value || "";
if (!text.trim()) return 0;
return text
.split(/\n\s*\n+/)
.map((s) => s.trim())
.filter(Boolean).length;
});
This is the part I think most counters quietly hand-wave away. "Reading time" is not a universal constant, and even sentence counting depends on punctuation conventions. Here the code at least makes the tradeoff explicit: CJK gets characters-per-minute, space-delimited languages get words-per-minute, and paragraphs mean blank-line-separated blocks.
Where it still gets fuzzy
There are a couple of honest limitations in the current implementation.
First, graphemes is a slightly misleading variable name. Array.from() counts code points, not full grapheme clusters. That means some visually single characters — like family emoji, flags, or emoji plus skin-tone modifiers — can still count as more than one.
Second, sentence splitting is intentionally simple. A regex like /[.!?。!?…]+/ will also split on things that are not really sentence boundaries, like 3.14 or e.g.. And the CJK detection threshold is still a heuristic, so a mixed-language paragraph near 35% can flip the "primary" unit in a way that feels a little arbitrary.
I wrapped that logic into a small free tool: Word & Character Counter.
Available in other languages
- 字數字元計數器 — 繁體中文
- 字数字符计数器 — 简体中文
- Word & Character Counter — English
- 文字数・文字カウンター — 日本語
- 글자수·단어수 카운터 — 한국어
- Compteur de mots et de caractères — Français
- Счётчик слов и символов — Русский
- Wörter- und Zeichenzähler — Deutsch
- Penghitung Kata dan Karakter — Bahasa Indonesia
- Contador de Palabras y Caracteres — Español
- Công Cụ Đếm Từ và Ký Tự — Tiếng Việt
- เครื่องมือนับคำและตัวอักษร — ไทย
- Licznik Słów i Znaków — Polski
- Kelime ve Karakter Sayacı — Türkçe
- Contatore di Parole e Caratteri — Italiano
- Contador de Palavras e Caracteres — Português
- Woorden- en Tekenteller — Nederlands
- Рахувач Слів і Символів — Українська
Top comments (0)