Grapheme-accurate rich-text truncation. Clamp multi-line text while preserving inline markup (<strong>, <em>, <code>, custom components) — no CSS line-clamp hacks, no DOM reflow.
"This bold paragraph is long enough to overflow…"
──── ─
kept cut here, marks intact
- Model content as a recursive tree of
text,break, andmarknodes - Flatten to grapheme-level tokens (via
Intl.Segmenter) preserving the mark stack on each - Measure line count for the full text using
@chenglou/pretext(canvasmeasureText, no DOM layout) - If overflow: binary-search the grapheme cut point that maximizes visible content within the line budget
- Rebuild the token slice back into a minimal rich-text tree
Two strategies: end (prefix + ellipsis) and middle (prefix + ellipsis + tail, e.g. file paths).
@trunky/react ──► @trunky/core ──► @chenglou/pretext
│
react 19 (peer)
| Package | Description |
|---|---|
@trunky/core |
Framework-agnostic truncation engine. Pure truncate(input) → result with rich-text model, binary-search solver, and pretext-backed measurement cache. |
@trunky/react |
React 19 compound component (Truncate.Root / Content / Toggle). Auto-measures via ResizeObserver + font loading, bridges JSX ↔ RichTextNode, manages expand/collapse state. |
playground |
Vite dev app with resizable demo frames. pnpm run play to launch. |
import { Truncate } from "@trunky/react";
<Truncate.Root strategy={{ kind: "end" }} clamp={{ maxLines: 2 }}>
<Truncate.Content>
This <strong>bold</strong> paragraph is long enough to overflow two lines
while keeping inline markup intact.
</Truncate.Content>
<Truncate.Toggle>
{(state) => (state.kind === "expanded" ? "Show less" : "Show more")}
</Truncate.Toggle>
</Truncate.Root>;Middle truncation (file paths):
<Truncate.Root
strategy={{ kind: "middle", keepTailGraphemes: 18 }}
clamp={{ maxLines: 1 }}
>
<Truncate.Content>
workspace/design-system/components/navigation/sidebar-entry.tsx
</Truncate.Content>
</Truncate.Root>@trunky/core does not need DOM layout, but its @chenglou/pretext measurement backend still needs Intl.Segmenter plus canvas measurement (OffscreenCanvas or a compatible 2D canvas context). So:
@trunky/corecan truncate during SSR only in runtimes that provide those primitives- plain Node SSR without canvas support cannot measure and should not call
truncate()at render time @trunky/reactcurrently renders full content on the server, then measures and truncates after mount on the client
trunky/
├── packages/
│ ├── core/ @trunky/core
│ └── react/ @trunky/react
├── apps/
│ └── playground/ dev playground (Vite)
├── tests/ Vitest browser tests (Playwright/Firefox)
├── vite.config.ts shared Vite + Vitest config
└── pnpm-workspace.yaml
pnpm install
pnpm run play # launch playground (Vite)
pnpm run test # Vitest browser tests (headless Firefox via Playwright)
pnpm run build # build core then react (tsdown)
pnpm run typecheck # tsc --noEmit across all packages- Grapheme segmentation — cuts at
Intl.Segmentergrapheme boundaries, so emoji (👨👩👧👦), combining characters, and complex scripts are never split mid-cluster. - Canvas measurement — pretext
prepare()+layout()avoids DOM reflow entirely. Expensive preparation is cached globally (~512 LRU entries); line counts are memoized per layouter instance. - Mark preservation — the flatten/rebuild cycle carries the full mark stack on every token, so marks around cut points reconstruct correctly without orphaned tags.
- Framework-agnostic core —
@trunky/corehas no React dependency. The genericMetatype parameter lets any framework map its element model onto the rich-text tree.