Emoji truncation breaking grapheme clusters
A preview snippet ends with half a flag. A display name limited to 20 characters shows a woman where the reader typed a woman developer, followed by an invisible character that fuses with the next word. A character counter says 18 of 20 while the field refuses to accept another letter.
Every one of these is String.prototype.slice or String.prototype.length being used on text that contains something other than plain Latin letters.
Root cause: three different meanings of “character”
JavaScript strings are sequences of UTF-16 code units. length counts those units, and slice indexes them. For text in the Basic Multilingual Plane written without combining marks, one unit is one code point is one visible character, and the three coincide — which is why code written against English text appears to work.
Outside that range they diverge in two steps. A code point above U+FFFF — every emoji, and much of the world’s less common scripts — is stored as a surrogate pair, so it is two code units. And a visible character may be several code points: a base letter plus combining marks, two regional indicators forming a flag, or a sequence of emoji joined by zero-width joiners with skin-tone modifiers attached.
Unicode calls the visible unit a grapheme cluster, and it is what a reader means by “character”. A length limit, a truncation, and a character counter are all statements about grapheme clusters, and all three are usually implemented against code units.
The damage from cutting mid-sequence is not only visual. A trailing zero-width joiner left at the end of a truncated string is invisible and still active: concatenated with following text, it can fuse with the next emoji and produce something neither string contained.
Minimal reproducible example
const name = '👩🏽💻 Ada';
name.length; // 11 — code units
[...name].length; // 8 — code points
name.slice(0, 3); // '👩🏽\u200d' — woman, skin tone, dangling joiner
// What a reader would call the length:
[...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(name)].length; // 5
The fix: segment, then slice on boundaries
const segmenters = new Map<string, Intl.Segmenter>();
function graphemes(locale: string): Intl.Segmenter {
let s = segmenters.get(locale);
if (!s) {
s = new Intl.Segmenter(locale, { granularity: 'grapheme' });
segmenters.set(locale, s);
}
return s;
}
export function visibleLength(text: string, locale = 'en'): number {
return [...graphemes(locale).segment(text)].length;
}
export function truncate(text: string, max: number, locale = 'en'): string {
const parts = [...graphemes(locale).segment(text)];
if (parts.length <= max) return text;
// The ellipsis occupies one of the allowed characters.
return parts.slice(0, max - 1).map((s) => s.segment).join('') + '…';
}
The segmenter is cached for the same reason a collator is: constructing it resolves locale data, and a truncation called once per row in a list would otherwise construct one per row.
Cutting prose: words, not characters
A hard grapheme cut is correct for a fixed-width field and poor for a text preview, where it ends mid-word. Intl.Segmenter handles that too, and it is one of the few ways to do it correctly in languages that do not separate words with spaces.
export function excerpt(text: string, maxChars: number, locale = 'en'): string {
if (visibleLength(text, locale) <= maxChars) return text;
const words = new Intl.Segmenter(locale, { granularity: 'word' });
let out = '';
for (const { segment } of words.segment(text)) {
if (visibleLength(out + segment, locale) > maxChars - 1) break;
out += segment;
}
return out.trimEnd() + '…';
}
Splitting on spaces would produce nothing usable for Japanese, Chinese or Thai, where a word boundary is a property of the text rather than a character in it. A segmenter with granularity: 'word' knows where those boundaries are because CLDR data says so — which is also why this is one more thing that silently degrades on a runtime without full locale data, as described in Intl polyfills and ICU data loading.
Making the limit, the counter and the backend agree
The most visible version of this bug is a form where three components disagree about how long the text is.
The counter under the field shows a number to the reader. The client validation decides whether the submit button is enabled. The server validation decides whether the request is accepted. If any of the three counts differently, the reader is told one thing and experiences another — and the usual arrangement has all three counting differently, because the counter was written in the component, the client rule in a schema, and the server rule in a database column length.
A database column declared VARCHAR(20) counts characters or bytes depending on the engine and encoding, and in neither case does it count graphemes. The practical resolution is to make the product limit a grapheme limit enforced in application code on both sides, and to size the column generously enough that it never becomes the binding constraint — a 20-grapheme limit needs far more than 20 bytes, since one emoji sequence can exceed 25 bytes on its own.
That last point catches teams whose column is sized in bytes: a limit of 20 characters that a reader satisfies with 20 emoji produces a value several hundred bytes long, and the insert fails with an error nobody can map back to the form.
Where truncation should not happen at all
Before fixing a truncation, it is worth asking whether the truncation belongs there. A surprising share of them exist to solve a layout problem that CSS solves better.
Overflowing a single line is what text-overflow: ellipsis is for, and it has three advantages over cutting the string: it never damages the text, it adapts when the container resizes, and the full value stays in the DOM where it can be copied and read by assistive technology. If the reason for truncating is that the text does not fit on one line, this is the answer.
Limiting a preview to a few lines is what -webkit-line-clamp does, with the same properties. A server-side excerpt generator producing a fixed character count will be wrong at every viewport width, whereas a line clamp is correct at all of them.
A genuine data constraint — a column, an external API, a printed label, an SMS — is where truncation is real, because the limit exists outside the browser. Those are also the cases where getting the count right matters most, since exceeding the limit is an error rather than an aesthetic issue.
The distinction is worth making explicitly during review, because a truncation added for layout reasons tends to survive long after the layout changed, and it damages values that are then stored and reused. A rule of thumb that holds up: if the truncated value is only ever displayed, use CSS; if it is stored, sent or printed, truncate the data and count in graphemes.
One further case deserves its own treatment: text that will be read aloud or indexed. A screen reader announces the DOM text, so a CSS-clipped string is announced in full, which is usually what you want. A hard-truncated string is announced truncated, ellipsis and all — so for anything where completeness matters, the CSS approach is not merely tidier but more accessible.
Verification
test.each([
['👩🏽💻 Ada', 5],
['🇩🇪🇫🇷', 2],
['é', 1],
['e\u0301', 1],
])('%s is %i visible characters', (text, expected) => {
expect(visibleLength(text)).toBe(expected);
});
test('truncation never leaves a partial sequence', () => {
const cut = truncate('👩🏽💻👩🏽💻👩🏽💻', 2);
expect(visibleLength(cut)).toBe(2); // one emoji plus the ellipsis
expect(cut).not.toMatch(/\u200d$/); // no dangling joiner
});
The dangling-joiner assertion is the one worth keeping permanently. It is the failure with no visible symptom in the truncated string itself, and it only manifests once the value is concatenated with something else.
When to escalate
If Intl.Segmenter is undefined, the runtime is older than the API or the data is missing. A polyfill exists, and the conditional loading pattern applies.
If lengths are correct in the application and wrong in the database, the column is counting bytes. Widen it and enforce the limit in code.
If a value renders correctly in one place and broken in another, the truncation is happening somewhere you did not write — a CSS text-overflow is safe, but a server-side snippet generator, a search index highlighter or a notification service may each be slicing independently.
FAQ
Is counting code points good enough?
Better than code units and still wrong. [...str].length splits surrogate pairs correctly, so a plain emoji counts as one — but a flag counts as two, a skin-toned emoji as three or more, and an accented letter written decomposed as two. Graphemes are the only count that matches what a reader sees.
Does this matter for a product that only supports English?
Yes, because the text is not yours. Readers paste names, emoji and other scripts into English interfaces constantly, and a truncation that breaks on pasted text is a bug regardless of which languages the interface is translated into.
What about Array.from(str).slice()?
It fixes surrogate pairs and nothing else. It will still cut a flag in half and still leave a dangling joiner, because those are sequences of complete code points.
Should the ellipsis count against the limit?
Yes, if the limit is a hard constraint such as a database column or an external API field. If it is a display limit, it matters less — but counting it is the behaviour readers expect from a counter, and consistency is worth more than the extra character.
Related
- Unicode Text Handling & Collation — segmentation alongside normalization and collation.
- Intl Polyfills & ICU Data Loading — what happens when
Intl.Segmenteris missing. - Text expansion clipping in fixed-width buttons — the layout counterpart of a length problem.
- Internationalizing Forms & Input — where counters and limits are shown to readers.
Part of Unicode Text Handling & Collation.