NNicheStackTutorials
← All tutorials
Frontend Tools

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.

·9 min read·Frontend Tools
wordcount: Twitter-Style Limits Without a Backend (And the Unicode Bug) cover

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.

Tags: Text ToolsUnicodeVanilla JSSEO