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, andundefinedfor 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 iterableSegmentsobject, not an array.Array.from(iterable, mapFn)both materializes it and projects each entry down to just its.segmentstring (the substring for that cluster) in one pass.- Truncation itself is then plain array logic: compare
graphemes.lengthtomaxLength, and if it's over,slicethe array (not the string) andjoin("")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.Segmenterwithgranularity: "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.