10LOC
Web Platformadvanced

Intl.Segmenter for emoji-safe text truncation

Published April 13, 2027

const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });

export const truncateGraphemes = (text: string, maxLength: number) => {
  const graphemes = Array.from(segmenter.segment(text), (s) => s.segment);
  if (graphemes.length <= maxLength) return text;
  return graphemes.slice(0, maxLength).join("") + "…";
};

What

truncateGraphemes cuts a string down to maxLength grapheme clusters — the units a person would actually count as "one character" — instead of maxLength UTF-16 code units, which is what string.slice() counts.

Why it matters

text.slice(0, 10) counts UTF-16 code units, and a huge range of real text isn't one code unit per visible character. A skin-tone-modified emoji, a family emoji joined with zero-width joiners, an emoji flag, or a base letter plus a combining accent mark can all be several code units — sometimes several code points — that together render as a single glyph. Slice through the middle of one and you get a broken glyph, an orphaned joiner, or (worse) a stray high surrogate that renders as a replacement-character box.

String.prototype.at() and spreading a string into an array both iterate by code point, which fixes surrogate pairs but not multi-code-point clusters like 👨‍👩‍👧‍👦 (four people joined with ZWJs, one grapheme) or é written as e + a combining acute accent. Intl.Segmenter is the built-in that actually understands Unicode's grapheme cluster boundaries — the same rule text renderers use to decide what one cursor movement or one backspace should delete.

How it works

  • new Intl.Segmenter(undefined, { granularity: "grapheme" }) is created once, at module scope — segmenters are relatively expensive to construct and cheap to reuse, and undefined for the locale just means "use the runtime's default," which is fine for grapheme boundaries since they're not locale-sensitive the way word boundaries are.
  • segmenter.segment(text) returns an iterable Segments object, not an array. Array.from(iterable, mapFn) both materializes it and projects each entry down to just its .segment string (the substring for that cluster) in one pass.
  • Truncation itself is then plain array logic: compare graphemes.length to maxLength, and if it's over, slice the array (not the string) and join("") the clusters back together, appending an ellipsis.

Gotchas

  • This counts grapheme clusters, not "display width." A wide CJK character and a combining-mark cluster both count as 1 here, even though they don't take the same horizontal space when rendered — this function answers "how many characters," not "how many pixels."
  • Re-running the segmenter on every call is the correct approach for this function's shape (it needs the full cluster list to slice), but if you're calling this in a tight loop over many strings, consider batching rather than re-segmenting per keystroke in something like a live character counter.
  • granularity: "word" and "sentence" use real locale-aware rules (e.g. deciding whether a period ends a sentence) — swapping to those without knowing the target locale-sensitivity can change results in ways "grapheme" never will.

Related

  • Intl.Segmenter with granularity: "word" — for word-safe truncation instead of character-safe, when you want to cut at word boundaries rather than mid-word.
  • Array.from(string) / spread — code-point-safe (fixes surrogate pairs) but not cluster-safe; fine for plain non-emoji Unicode text, insufficient for anything with combining marks or ZWJ sequences.