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.

Code points and code units per visible character A Latin letter and a precomposed accented letter are one code point and one UTF-16 unit each. A regional-indicator flag is two code points and four units, and cutting it in half leaves a stray letter. A professional emoji with a skin tone and a joiner is four code points and seven units, and cutting it produces a different emoji followed by invisible junk. What one visible character is made of Code points UTF-16 units Cut at 10 units gives a 1 1 fine é (precomposed) 1 1 fine 🇩🇪 flag 2 4 one lone letter 👩🏽‍💻 professional 4 7 a woman, then junk
The bottom two rows are single characters to every reader and multi-unit sequences to every substring call.

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.

A slice through the middle of an emoji sequence Slicing a professional emoji at three UTF-16 units keeps the woman and the skin tone modifier and the zero-width joiner, dropping the profession. The result renders as a different emoji trailed by an invisible joiner that can fuse with whatever text follows it. What a naive slice produces Original 👩🏽‍💻 shipped it slice(0,3) Cut mid-sequence woman + skin tone + ZWJ Rendered 👩🏽 plus an invisible joiner The dangling joiner can fuse with whatever text follows it.
The visible damage is one wrong emoji. The invisible damage is a dangling joiner that alters later text.

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.

Five rules for cutting user text Length is counted in grapheme clusters using a segmenter. Slicing happens on segment boundaries rather than at a numeric index. The ellipsis is counted against the limit. For prose, cutting at a word boundary reads better than a hard grapheme cut. And the character counter shown to the reader uses the same measure as the limit that enforces it. Truncating safely 1 Count in graphemes, not units Intl.Segmenter granularity grapheme 2 Slice on segment boundaries never on an index into the string 3 Reserve room for the ellipsis it is a character too 4 Prefer a word boundary when there is one granularity word, for prose 5 Apply the same rule to the counter or the limit and the count disagree
The last rule is the one readers notice: a counter that disagrees with the limit reads as a broken form.

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.

Part of Unicode Text Handling & Collation.