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
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", "Γ©"]
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
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);
}
.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); }
}
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 }
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:
-
indexis in code units. That's convenient, becausetext.slice(s.index, s.index + s.segment.length)gives you the segment back. Just don't mix it with code-point positions from[...text]. -
isWordLikeonly exists in word mode. With"grapheme"or"sentence"it isundefined, notfalse. - 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.
-
newis mandatory. CallingIntl.Segmenter()without it throws aTypeError. -
Fallback. If you still have to support older engines, check
"Segmenter" in Intl. The demo falls back toArray.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
.lengthis 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.
isWordLikefixes that without a dictionary of your own.
Sources
- Intl.Segmenter - MDN
- Intl.Segmenter() constructor - MDN
- Segments.prototype.containing() - MDN
- Baseline status definition - web-features
Originally published at richi.codes.
Top comments (0)