graphemely

How to truncate a string with emoji in JavaScript

Use truncate(str, maxLength) from graphemely. It counts grapheme clusters, the characters a person actually sees, so it never cuts an emoji, a flag, a skin tone or an accented letter in half. The ellipsis counts toward the limit, so the result always fits.

The fix, copy and paste

example.js
import { truncate } from '@kwy404/graphemely';

truncate('Hello world', 8);                          // 'Hello w…'
truncate('Hello world', 8, { wordBoundary: true });  // 'Hello…'
truncate('Hello world', 8, { ellipsis: '...' });     // 'Hello...'

const str = 'Hi' + '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}' + '!';  // a family emoji between two words
truncate(str, 3);                                    // 'Hi…', the family is dropped whole

Emoji in the code samples are written as JavaScript escapes, for example \u{1F468} for a man. In your own code you can type the emoji directly.

Why str.slice(0, n) + '...' breaks emoji

JavaScript strings are sequences of UTF-16 code units. slice, substring and substr count code units, but most emoji take two code units (a surrogate pair), and many are built from several code points: a family of three is 8 code units, a flag is 4, a thumbs up with a skin tone is 4. Cutting at an arbitrary code unit index lands inside those sequences.

Broken: native slice
example.js
const str = 'Hi' + family + '!';
str.slice(0, 3) + '...';
// 'Hi\uD83D...'  half of an emoji
str.slice(0, 5) + '...';
// 'Hi' + man + ZWJ + '...'  one lonely person
Fixed: graphemely
example.js
import { truncate } from '@kwy404/graphemely';

truncate(str, 3);
// 'Hi…'
truncate(str, 4);
// unchanged, it already fits

The leftover \uD83D is a lone surrogate. It renders as a replacement box, makes encodeURIComponent throw a URIError, and can be rejected by APIs that validate UTF-8.

Options

OptionDefaultWhat it does
ellipsis'…'Text appended when the string is cut. A single character by default. Counts toward maxLength.
wordBoundaryfalseCut at the last space that fits instead of in the middle of a word, and drop trailing spaces. Falls back to a hard cut for one very long word.

If the ellipsis does not leave room for any text, the string is cut to maxLength graphemes without an ellipsis. A negative or NaN limit throws a RangeError.

Without a library

Modern runtimes have Intl.Segmenter, which graphemely uses under the hood. The minimal hand written version looks like this:

example.js
function truncate(str, max, ellipsis = '…') {
  const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
  const parts = Array.from(segmenter.segment(str), (s) => s.segment);
  if (parts.length <= max) return str;
  return parts.slice(0, max - 1).join('') + ellipsis;
}

It works, but it does not handle runtimes without Intl.Segmenter, word boundaries, multi character ellipses or invalid input. graphemely does, in about 1.3 kB gzipped when you only import truncate.

Install

Terminal
npm install @kwy404/graphemely

Works in Node 16 and newer, all modern browsers, Deno and Bun. ESM and CommonJS, TypeScript types included, zero dependencies, about 2 kB gzipped. Try your own text in the playground.

FAQ

Use truncate(str, maxLength) from graphemely. It counts grapheme clusters instead of UTF-16 code units, so an emoji is never cut in half.

Yes. truncate(str, 10) returns at most 10 visible characters including the ellipsis, so the result always fits the space you have.

lodash uses a regular expression that approximates grapheme clusters. It handles common emoji but splits Hangul syllables written with conjoining jamo, counts the England flag as 7 characters and Thai SARA AM as a separate character.

Yes, pass { wordBoundary: true }. graphemely cuts at the last whitespace that fits and removes trailing spaces before the ellipsis.

Related guides