wordcount: Twitter-Style Limits Without a Backend (And the Unicode Bug)
Meta description counter for a static blog editor. Grapheme-aware counting fixed the emoji length mismatch that cost me a rejected guest post.
Problem: Wrote a meta description with two emoji flags. Google Search Console showed 168 characters. My counter said 152. Google counts grapheme clusters, not UTF-16 code units.
Full counter tool
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Character & Word Counter</title>
<style>
body { font-family: system-ui; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
textarea { width: 100%; min-height: 200px; font-size: 16px; line-height: 1.5; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 1rem; }
.stat { background: #f1f5f9; padding: 1rem; border-radius: 8px; text-align: center; }
.stat strong { display: block; font-size: 1.5rem; }
.warn { color: #b45309; }
.over { color: #b91c1c; font-weight: 600; }
</style>
</head>
<body>
<h1>Character & Word Counter</h1>
<textarea id="text" placeholder="Type or paste..."></textarea>
<div class="stats">
<div class="stat"><strong id="chars">0</strong> characters</div>
<div class="stat"><strong id="graphemes">0</strong> graphemes</div>
<div class="stat"><strong id="words">0</strong> words</div>
</div>
<p id="meta-hint"></p>
<script>
const text = document.getElementById('text');
const chars = document.getElementById('chars');
const graphemes = document.getElementById('graphemes');
const words = document.getElementById('words');
const metaHint = document.getElementById('meta-hint');
const META_LIMIT = 160;
const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter
? new Intl.Segmenter('en', { granularity: 'grapheme' })
: null;
function countGraphemes(s) {
if (!s) return 0;
if (segmenter) return [...segmenter.segment(s)].length;
return [...s].length;
}
function countWords(s) {
const t = s.trim();
if (!t) return 0;
return t.split(/\s+/).filter(Boolean).length;
}
function update() {
const v = text.value;
const c = v.length;
const g = countGraphemes(v);
chars.textContent = c;
graphemes.textContent = g;
words.textContent = countWords(v);
const used = g;
metaHint.className = '';
if (used > META_LIMIT) {
metaHint.textContent = 'Meta description: ' + used + '/' + META_LIMIT + ' — over limit';
metaHint.classList.add('over');
} else if (used > META_LIMIT - 20) {
metaHint.textContent = 'Meta description: ' + used + '/' + META_LIMIT + ' — getting close';
metaHint.classList.add('warn');
} else {
metaHint.textContent = 'Meta description: ' + used + '/' + META_LIMIT;
}
}
text.addEventListener('input', update);
update();
</script>
</body>
</html>
Why string.length lies
'👨👩👧'.length // 8 in JS (surrogate pairs + ZWJ)
countGraphemes('👨👩👧') // 1 with Intl.Segmenter
Fallback for old Safari
If Intl.Segmenter missing, [...s].length is better than .length but still wrong for ZWJ families. I show a small footnote on unsupported browsers.
Reading time bonus
const readingMin = Math.max(1, Math.ceil(countWords(v) / 200));
Append to stats grid for blog draft workflow.
Verify
Paste Hello 🌍 — graphemes should be 7, chars may be 8.
Paste 161-char meta — red warning fires.