DEV Community

Cover image for Intl.Segmenter: split text without breaking emoji
richi.codes
richi.codes

Posted on Originally published at richi.codes

Intl.Segmenter: split text without breaking emoji

This note has an interactive demo. Try it in the original

If you have ever animated a headline letter by letter and watched an emoji turn into a row of broken boxes, the problem was never your CSS: it was split(""). Intl.Segmenter splits text into the pieces a reader actually perceives, and as of October 16, 2026 it is Baseline Widely available.

Type anything into the demo above. The top row is what text.split("") gives you; the bottom row is Intl.Segmenter. With a family emoji, a flag and a word with an accent, the difference is hard to miss.

Why split("") lies to you

JavaScript strings are sequences of UTF-16 code units. length, split("") and index access all work in those units, not in characters as a person sees them:

const text = "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ cafΓ©";

text.length;            // 16
text.split("").length;  // 16 β†’ lone surrogate halves
[...text].length;       // 12 β†’ code points: the family falls apart
Enter fullscreen mode Exit fullscreen mode

Spreading the string ([...text]) is better, because it iterates by code points, but it is still not what you want. That family emoji is seven code points: four people glued together with three zero-width joiners. A flag is two regional indicator symbols. An accent can be a separate combining mark after the letter. None of that is a "character" to the reader.

What Intl.Segmenter does

Intl.Segmenter performs locale-sensitive text segmentation. You create one with a locale and a granularity, and its segment() method returns an iterable Segments object:

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

[...segmenter.segment(text)].map((s) => s.segment);
// ["πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", " ", "c", "a", "f", "Γ©"]
Enter fullscreen mode Exit fullscreen mode

granularity takes three values:

  • "grapheme" (the default): user-perceived characters, the grapheme clusters.
  • "word": word boundaries, as determined by the locale.
  • "sentence": sentence boundaries.

Each segment is an object with segment (the text), index (where it starts in the original string, in code units) and input (the whole string). With granularity: "word" you also get isWordLike, which is true for letters, numbers or ideographs and false for spaces and punctuation. That makes word counting a one-liner:

const words = new Intl.Segmenter("en", { granularity: "word" });
const countWords = (str) =>
  [...words.segment(str)].filter((s) => s.isWordLike).length;

countWords("Hello, world! It’s 2026."); // 4
Enter fullscreen mode Exit fullscreen mode

Word mode is where the API really earns its keep. Splitting on spaces does nothing for languages that don't put spaces between words, such as Japanese, Chinese or Thai. The MDN example segments εΎθΌ©γ―ηŒ«γ§γ‚γ‚‹γ€‚εε‰γ―γŸγ¬γγ€‚ into 吾輩 | は | 猫 | で | ある | 。 | 名前 | は | たぬき | 。 and counts 8 words. Hit the "Japanese" preset in the demo: split(" ") returns the whole sentence as one piece, and the segmenter returns the words.

The split-text animation, done right

This is the core of the demo above. Each grapheme goes into its own <span>, with its position in a custom property so CSS can stagger the animation:

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

for (const el of document.querySelectorAll(".split")) {
  const pieces = [...segmenter.segment(el.textContent)].map(({ segment }, i) => {
    const span = document.createElement("span");
    span.textContent = segment;
    span.style.setProperty("--i", i);
    return span;
  });
  el.replaceChildren(...pieces);
}
Enter fullscreen mode Exit fullscreen mode
.split span {
  display: inline-block;
  animation: pop 0.5s both;
  animation-delay: calc(var(--i) * 45ms);
}
@keyframes pop {
  from { opacity: 0; transform: translateY(0.5em) scale(0.6); }
}
Enter fullscreen mode Exit fullscreen mode

The family emoji stays in one span, the flag stays a flag, and an accented letter keeps its accent. The demo wraps the same idea in a prefers-reduced-motion check so the animation goes away for people who asked for less motion.

Finding the segment at a position

Segments has one more method that is easy to miss: containing(index). You give it a code-unit index and it returns the segment that covers it, or undefined if the index is out of range:

const sentence = "Find the word under the cursor.";
words.segment(sentence).containing(11);
// { segment: "word", index: 9, input: "...", isWordLike: true }
Enter fullscreen mode Exit fullscreen mode

That's exactly what you need to answer "which word is under the caret?" from an input's selectionStart. In the demo, tapping or focusing a piece in the bottom row calls containing() and shows the segment, its index, its .length in code units and the code points inside it. The family emoji reports a .length of 11.

Support and gotchas

According to MDN's browser compatibility data, Intl.Segmenter shipped in Chrome and Edge 87, Safari 14.1 (iOS 14.5) and Firefox 125, and in Node.js 16. web-features marks it Baseline Newly available on April 16, 2024, when Firefox 125 completed the set, and Widely available on October 16, 2026. Widely available is defined as 30 months after that keystone date.

Things to keep in mind:

  • index is in code units. That's convenient, because text.slice(s.index, s.index + s.segment.length) gives you the segment back. Just don't mix it with code-point positions from [...text].
  • isWordLike only exists in word mode. With "grapheme" or "sentence" it is undefined, not false.
  • It is locale-sensitive. Pass the language of the text, not the language of your UI, when they differ. If you pass nothing, the runtime's default locale is used.
  • new is mandatory. Calling Intl.Segmenter() without it throws a TypeError.
  • Fallback. If you still have to support older engines, check "Segmenter" in Intl. The demo falls back to Array.from(), which at least never splits surrogate pairs, and shows a warning. MDN links to a FormatJS polyfill if you need the real behavior.

What to keep an eye on

  • Your own split-text code. If a letter animation, a typewriter effect or a "reveal" component in your codebase uses split("") or [...text], try it with an emoji or a combining accent. Now that the API is Widely available, swapping in a grapheme segmenter is a small change.
  • Character counters. Any "280 characters left" counter built on .length is counting code units. Segmenting by grapheme gives you a number closer to what the user sees.
  • Word mode outside English. If your content is multilingual, word counts and "read time" estimates based on spaces are wrong for some languages. isWordLike fixes that without a dictionary of your own.

Sources


Originally published at richi.codes.

Top comments (0)