Skip to content

TruncateText

SM · 950B gzip budget

Truncation that counts user-perceived characters, so emoji and Devanagari clusters are never split.

Category
Internationalisation
Budget
sm, single-purpose control
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale
A headline long enough t…

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Why this exists

TruncateText

English

slice counts UTF-16 code units, so it cuts a family emoji in half and leaves the fragments to render as tofu.

Formatting shortcut

Sunset over the sea 👨…

text.slice(0, 22)

TruncateText

Sunset over the sea 👨‍👩‍👧‍👦

truncate-text

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/truncate-text.json

Copies the source into your project. Pulls in 2 shared registry items: use-locale, locale.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useMemo } from "react";
import { useLocale } from "../lib/use-locale";

export interface TruncateTextProps {
  text: string;
  /** Maximum length, counted in user-perceived characters. */
  limit: number;
  /** Appended when the text is shortened. */
  ellipsis?: string;
}

/**
 * Truncation that counts what a reader counts.
 *
 * `text.slice(0, n)` counts UTF-16 code units, which is not what anyone means
 * by "characters". It splits an emoji into halves that render as tofu, cuts a
 * flag apart into two letters, and severs Devanagari and Thai text mid-cluster
 * so the remaining glyph is a different, wrong letter.
 *
 * `Intl.Segmenter` with granularity "grapheme" segments by user-perceived
 * character, which is the unit a limit should actually be expressed in. The
 * full text stays available to assistive technology via the title attribute,
 * so nothing is lost to a screen reader that was visible to anyone else.
 */
export function TruncateText({ text, limit, ellipsis = "…" }: TruncateTextProps) {
  const { locale } = useLocale();

  const truncated = useMemo(() => {
    if (typeof Intl.Segmenter !== "function") {
      // A runtime without Segmenter is old enough that [...text] — which at
      // least splits by code point rather than code unit — is the best
      // available approximation.
      const points = [...text];
      return points.length <= limit ? text : points.slice(0, limit).join("");
    }

    const segmenter = new Intl.Segmenter(locale, { granularity: "grapheme" });
    const graphemes = [...segmenter.segment(text)].map((entry) => entry.segment);

    return graphemes.length <= limit ? text : graphemes.slice(0, limit).join("");
  }, [text, limit, locale]);

  if (truncated === text) return <>{text}</>;

  return <span title={text}>{truncated + ellipsis}</span>;
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation