Skip to content

tonyblu331/truncate

Repository files navigation

truncate: measured text truncation cover

truncate: DOM-free core text truncation for JavaScript

npm downloads bundle size license

DOM-free core, grapheme-safe text truncation for JavaScript and TypeScript, powered by @chenglou/pretext. Fit copy by pixel width, line count, target string, explicit range, or measured height without layout reads; opt into element binding only when component code already owns a DOM-compatible node.

Use it when CSS ellipsis is too blunt: search snippets, support queues, command palettes, log rows, table cells, filenames, URLs, and previews where the important text can be buried in the middle.

What It Solves

@tonybonet/truncate is a small text truncation utility for UI code that needs predictable measured output without reading browser layout. It helps with:

  • Pixel-width truncation for labels, table cells, and compact controls
  • Multi-line truncation for summaries and previews
  • Middle and start truncation for emails, filenames, hashes, and URLs
  • Match-aware truncation that keeps a search hit, ID, or error code visible
  • Range-aware truncation when a search/indexing layer already provides offsets
  • Grapheme-safe truncation for emoji, accents, and joined characters

It is not a CSS replacement. Use CSS text-overflow: ellipsis when simple visual clipping is enough; use this library when the truncated string itself needs to be computed, tested, copied, indexed, or rendered outside normal DOM layout.

Install

pnpm add @tonybonet/truncate
bun add @tonybonet/truncate
npm install @tonybonet/truncate
yarn add @tonybonet/truncate

Runtime measurement uses Canvas2D: browser canvas, OffscreenCanvas, or a Node canvas polyfill.

Tree-shake-Friendly Imports

The root entry exports the full API. For tighter application bundles, import the smaller subpath that matches the job:

import { truncateByWidth } from "@tonybonet/truncate/width";
import { truncateByLines, measureHeight } from "@tonybonet/truncate/lines";
import { truncateAround, truncateRange } from "@tonybonet/truncate/range";
import { createTruncator } from "@tonybonet/truncate/factory";

These subpaths keep the public API split by behavior while preserving the normal root import for convenience.

Why It Exists

CSS ellipsis is fine when you only need overflow: hidden. Product UI usually needs more:

  • Keep invoice #1042 visible inside a long support note
  • Preserve a matched search range from known offsets
  • Truncate a filename, email, hash, or URL from the middle or start
  • Fit multi-line previews without DOM measurement
  • Use CSS-like widths such as 12rem, 40ch, or 50vw
  • Avoid slicing emoji, accents, and joined grapheme clusters in half

This library gives you those behaviors as plain functions. No DOM measurement loop, no layout flicker, no splitting emoji or joined graphemes in half.

Common Questions

Can it truncate around a word in the middle of a paragraph?

Yes. Use truncateAround with a target string. The result reports metrics.rangePreserved so callers can tell whether the target fit whole.

Can it preserve a range from search offsets?

Yes. Use truncateRange with start and end grapheme offsets. This avoids re-matching text when your search layer already knows where the relevant span is.

Does it need the DOM?

Core functions do not require DOM layout reads. Measurement uses Canvas2D through browser canvas, OffscreenCanvas, or a Node canvas polyfill. The optional element-bound helper reads style and width once when you pass it to createTruncator(element), then reads textContent per call.

Quick Start

import { createTruncator, truncate, truncateAround, truncateRange } from "@tonybonet/truncate";

truncate("A very long string", {
  font: "16px Inter",
  maxWidth: 100,
});
// -> width truncation

truncate("A very long string", {
  font: "16px Inter",
  maxWidth: 140,
  ellipsis: " READ MORE",
});
// -> custom truncation marker

truncate(longArticle, {
  font: "16px Inter",
  maxWidth: 320,
  maxLines: 3,
});
// -> line truncation

truncateAround(longArticle, {
  font: "16px Inter",
  maxWidth: "22rem",
  target: "invoice #1042",
  context: 8,
});
// -> preserves the matched text when it can fit

truncateRange(longArticle, {
  font: "16px Inter",
  maxWidth: "40ch",
  start: 120,
  end: 132,
  before: 8,
  after: 8,
});
// -> preserves the explicit grapheme range when it can fit

const truncator = createTruncator({
  font: "16px Inter",
  lineHeight: 22,
});

truncator.truncateByLines(longArticle, { maxWidth: 320, maxLines: 3 });
truncator.measureHeight("Hello\nworld", { maxWidth: 320 });

Power Recipes

Keep a Search Hit Visible

Use this for log previews, search results, moderation queues, command palettes, and table cells where the important part is in the middle.

const result = truncateAround(supportNote, {
  font: "14px Geist Mono",
  maxWidth: 240,
  target: "invoice #1042",
  context: 10,
});

result.text;
result.metrics.rangePreserved;

If the target itself is too wide, rangePreserved becomes false and the target is squeezed instead of lying to you. The API tells you when the impossible thing was impossible.

Preserve Known Offsets

If your search/indexing layer already gives offsets, skip string matching and use truncateRange.

truncateRange(article, {
  font: "16px Inter",
  maxWidth: 300,
  start: match.start,
  end: match.end,
  before: 12,
  after: 12,
});

Offsets are grapheme offsets. That matters for emoji, accents, and joined sequences.

Use CSS-like Widths

truncateByWidth(title, { font: "16px Inter", maxWidth: "18rem" });
truncateByWidth(code, { font: "13px Geist Mono", maxWidth: "42ch" });
truncateByLines(summary, { font: "16px Inter", maxWidth: "50vw", maxLines: 2 });

Supported units: px, bare numbers, rem, em, ch, vw, vh, vmin, vmax.

Unsupported layout-relative units such as % and fr throw a descriptive TypeError.

Pre-bind Repeated Options

const bodyText = createTruncator({
  font: "16px Inter",
  lineHeight: 24,
  wordBreak: "normal",
});

bodyText.truncateByWidth(title, { maxWidth: "32ch" });
bodyText.truncateByLines(summary, { maxWidth: 360, maxLines: 3 });
bodyText.truncateAround(logLine, { maxWidth: 280, target: "ERROR", context: 12 });

Use Custom Markers

ellipsis is just the suffix string. It can be one character, five characters, or words. The library computes the text; visual fades stay in your UI layer.

truncateByWidth(mediaTitle, { font: "16px Inter", maxWidth: 220, ellipsis: "." });
truncateByWidth(gifName, { font: "16px Inter", maxWidth: 220, ellipsis: "....." });
truncateByWidth(articleIntro, { font: "16px Inter", maxWidth: 260, ellipsis: " READ MORE" });

Bind to a DOM-Compatible Element

When you already have a real DOM element, custom element, or DOM-compatible object, bind once. After that, calls do not take an element reference, font, or text. The library reads the element's computed typography internally and uses the element's rendered width as maxWidth by default. Pass maxWidth only when you want an operation-specific override; that override can be a number or CSS width such as 18rem, 32em, 40ch, 50vw, or 50vh.

const title = document.querySelector("[data-truncate]")!;
const titleTruncator = createTruncator(title);

titleTruncator.truncate();
titleTruncator.truncate({ maxLines: 2 });
titleTruncator.truncateMiddle({ maxWidth: "40ch", ellipsis: "....." });

The bound truncator avoids compounded truncation: if it wrote a previous result, the next call still uses the last source text unless external code changes textContent.

The plain truncate(text, options) entry stays text-only. This keeps the DOM boundary explicit and avoids a second DOM-shaped entry point.

For custom renderers, tests, or non-browser integrations, pass a small adapter with nodeType: 1, textContent, a width source, and optional computedStyle:

const label = {
  nodeType: 1 as const,
  textContent: "A long label that needs truncation",
  getBoundingClientRect: () => ({ width: 180 }),
  computedStyle: {
    fontSize: "16px",
    fontFamily: "Inter, sans-serif",
    lineHeight: "normal",
  },
};

createTruncator(label).truncate();

See docs/element-bound.md for the full adapter contract, width requirement, maxWidth override rules, style inference rules, and no-DOM TypeScript notes.

API

truncate(text, options)

Single entry point. Uses line truncation when maxLines or keepLines is present; otherwise uses width truncation.

truncate("Hello world", { font: "16px Inter", maxWidth: 100 });
truncate("Hello world", { font: "16px Inter", maxWidth: 100, maxLines: 3 });

truncateByWidth(text, options)

Single-line width truncation.

truncateByWidth("A very long string", {
  font: "16px Inter",
  maxWidth: 100,
  ellipsis: "...",
});

truncateByLines(text, options)

Multi-line truncation with optional maxLines or keepLines.

truncateByLines(longArticle, {
  font: "16px Inter",
  maxWidth: 320,
  lineHeight: 22,
  maxLines: 3,
});

truncateMiddle(text, options)

Keeps the start and end visible.

truncateMiddle("user@example.com", {
  font: "14px Geist Mono",
  maxWidth: 100,
});

truncateStart(text, options)

Keeps the suffix visible.

truncateStart("a3f2c8b1e9d04a7f", {
  font: "13px Geist Mono",
  maxWidth: 80,
});

truncateAtOffset(text, options)

Anchors truncation around a grapheme offset. Negative offsets resolve from the end.

truncateAtOffset(longText, {
  font: "16px Inter",
  maxWidth: 200,
  offset: -12,
});

truncateAround(text, options)

Keeps the first matching target string visible.

truncateAround(longArticle, {
  font: "16px Inter",
  maxWidth: 220,
  target: "invoice #1042",
  context: 8,
});

truncateRange(text, options)

Keeps an explicit grapheme range visible.

truncateRange(longArticle, {
  font: "16px Inter",
  maxWidth: 220,
  start: 120,
  end: 132,
  before: 8,
  after: 8,
});

measureHeight(text, options)

Measures rendered height for a width and line height.

measureHeight("Hello\nworld", {
  font: "16px Inter",
  maxWidth: 320,
  lineHeight: 22,
});

createTruncator(config)

Pre-binds shared options for repeated text calls.

const t = createTruncator({ font: "16px Inter", lineHeight: 22 });

t.truncateByWidth("Hello", { maxWidth: 200 });
t.truncateByLines(longArticle, { maxWidth: 320, maxLines: 3 });
t.measureHeight("Hello\nworld", { maxWidth: 320 });

createTruncator(element)

Component convenience for DOM or DOM-compatible code. It accepts any DOMCompatibleElement: normal DOM nodes, custom elements, or objects that expose nodeType, textContent, a width source, and optional computedStyle. It reads typography and width at creation time, reads current textContent per call, and writes the truncated text back without compounding prior write-back. You do not pass font for normal element-bound calls; only pass maxWidth when overriding the element width for a specific operation.

const element = document.querySelector("[data-truncate]")!;
const t = createTruncator(element);

t.truncate();
t.truncate({ maxLines: 2 });
t.truncateStart({ maxWidth: "32ch", ellipsis: " READ MORE" });

For custom adapters, provide the DOM-compatible surface directly:

const element = {
  nodeType: 1 as const,
  textContent: "A long label that needs truncation",
  getBoundingClientRect: () => ({ width: 180 }),
  computedStyle: {
    fontSize: "16px",
    fontFamily: "Inter, sans-serif",
    lineHeight: "normal",
  },
};

createTruncator(element).truncate();

Custom adapters without computedStyle use safe font defaults and do not call browser getComputedStyle. Every adapter must still provide clientWidth or getBoundingClientRect().width before binding.

detectFont() and register(selector, config)

Use explicit font for SSR, workers, and deterministic tests. In browser UI, you can detect or register reusable font shorthands.

register("body", { font: "18px Geist" });

truncateByWidth("Hello", {
  selector: "body",
  maxWidth: 160,
});

Options

Base Options

Option Type Default Used by
font string auto-detect in browser measurement APIs
selector string - font lookup
maxWidth CssWidth required unless bound truncation and measurement
ellipsis string custom marker or suffix
maxLines number 1 truncate, truncateByLines
keepLines number[] - truncate, truncateByLines
lineHeight number 20 for line truncation lines and height
wordBreak "normal" | "keep-all" "normal" Pretext
letterSpacing number - Pretext
whiteSpace "normal" | "pre-wrap" "normal" Pretext

Range and Target Options

Option Type Used by
target string truncateAround
offset number truncateAtOffset
start number truncateRange
end number truncateRange
context number truncateAround, truncateRange
before number truncateAround, truncateRange
after number truncateAround, truncateRange

Types

type CssWidth = number | string;

type WordBreakMode = "normal" | "keep-all";
type WhiteSpaceMode = "normal" | "pre-wrap";

interface TruncateResult {
  text: string;
  original: string;
  truncated: boolean;
  metrics: {
    originalLineCount: number;
    rangePreserved?: boolean;
  };
}

interface BoundTruncator {
  truncate(opts?: Partial<TruncateOptions>): TruncateResult;
  truncateByWidth(opts?: Partial<TruncateOptions>): TruncateResult;
  truncateByLines(opts?: Partial<TruncateOptions>): TruncateResult;
  truncateMiddle(opts?: Partial<TruncateOptions>): TruncateResult;
  truncateStart(opts?: Partial<TruncateOptions>): TruncateResult;
  truncateAtOffset(opts?: Partial<TruncateOptions & { offset?: number }>): TruncateResult;
  truncateRange(opts?: Partial<TruncateOptions & { start?: number; end?: number }>): TruncateResult;
  truncateAround(
    opts?: Partial<
      TruncateOptions & { target?: string; context?: number; before?: number; after?: number }
    >,
  ): TruncateResult;
  measureHeight(opts?: Partial<MeasureOptions>): number;
}

interface DOMNativeElementLike {
  readonly nodeType: number;
  textContent: string | null;
  readonly clientWidth: number;
  getBoundingClientRect: () => { width?: number };
}

type DOMCompatibleAdapter = {
  readonly nodeType: 1;
  textContent: string | null;
  readonly computedStyle?: Record<string, string | undefined>;
} & (
  | { readonly clientWidth: number; getBoundingClientRect?: () => { width?: number } }
  | { readonly clientWidth?: number; getBoundingClientRect: () => { width?: number } }
);

type DOMCompatibleElement = DOMNativeElementLike | DOMCompatibleAdapter;

Notes

  • wordBreak, letterSpacing, and whiteSpace are passed to Pretext.
  • Empty text returns empty text with truncated: false.
  • maxWidth <= 0 returns empty text with truncated: true.
  • If an anchored target or range cannot fit whole, rangePreserved reports that honestly.

Credits

Built on @chenglou/pretext by @chenglou, which handles the hard text measurement and line-breaking work.

License

MIT © 2026 Antonio Bonet

About

DOM-free, grapheme-safe text truncation powered by Pretext

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors