@tonybonet/chop
v2.0.0
Published
Pretext-powered text splitting for words, lines, characters, and paragraphs
Downloads
104
Maintainers
Readme
@tonybonet/chop
Pretext-powered text splitting for animation systems. Chop gives you words, lines, characters, and paragraphs that match canvas-measured layout while the original DOM text stays accessible.
Install
pnpm add @tonybonet/chopnpm install @tonybonet/chopPick The API
| Need | Use | You get |
| --- | --- | --- |
| Animate existing browser text | chop(source) | DOM elements |
| Measure text and render your own spans | layoutText(text, font, options) | layout handles |
| Reuse a font string safely | defineFont(cssFont) | typed font descriptor |
| Select subsets for stagger logic | select(handles, query) | filtered handles |
| Clear long-lived measurement caches | clearPretextCache() | cache cleanup |
DOM API
import { chop } from "@tonybonet/chop";
const title = chop(".hero");title is an array of word elements, so it works directly with GSAP, WAAPI,
Motion, or plain DOM code.
gsap.from(title, {
y: 40,
opacity: 0,
stagger: 0.05,
});Extra units and metadata live on the same array:
title.lines; // readonly HTMLElement[]
title.chars; // readonly HTMLElement[]
title.paragraphs; // readonly HTMLElement[]
title.meta; // ChopMeta
title.metrics; // ChopFontMetrics | null
title.relayout(); // recompute and reproject
title.destroy(); // remove overlay and restore the source elementWords are projected immediately. Lines, characters, and paragraphs are lazy, so this:
const title = chop(".hero");does not create character spans until you ask for them:
title.chars;Pure Layout API
Use pure mode when a framework should own the markup.
import { defineFont, layoutText } from "@tonybonet/chop";
const font = defineFont("700 48px Inter");
const result = layoutText("Hello world", font, {
width: 360,
whiteSpace: "normal",
});
result.words.map((word) => word.text);
result.meta.lineCount;Pure mode does not touch the DOM, but it still needs a browser-like canvas environment because Pretext measures text. In SSR, run it on the client or provide a canvas implementation.
ChopMeta
ChopMeta is the summary of the measured layout. It is not animation state. It
answers questions like "how many words do I have?" and "did this wrap?".
interface ChopMeta {
readonly lineCount: number;
readonly wordCount: number;
readonly charCount: number;
readonly paragraphCount: number;
readonly height: number;
readonly naturalWidth: number;
readonly preserveLineBreaks: boolean;
readonly preserveSpaces: boolean;
readonly keepWordsTogether: boolean;
readonly fontMetrics: ChopFontMetrics | null;
}Example:
const headline = chop(".headline");
const target = headline.meta.lineCount > 1 ? headline.lines : headline;
gsap.from(target, {
yPercent: 100,
stagger: 0.08,
});Handles
layoutText() returns handles. A handle is a layout record for one unit of text.
interface ChopHandle {
id: string;
unit: "line" | "word" | "char" | "paragraph";
text: string;
index: number;
lineIndex?: number;
wordIndex?: number;
paragraphIndex?: number;
isWhitespace: boolean;
start: { segmentIndex: number; graphemeIndex: number };
end: { segmentIndex: number; graphemeIndex: number };
rect?: ChopRect;
}Handles preserve relationships. A character can know which word and line it belongs to. A word can know which paragraph it belongs to. That keeps custom rendering and stagger logic straightforward.
const result = layoutText("Make text move", "500 32px Inter");
const firstLineChars = result.chars.filter((char) => char.lineIndex === 0);Options
interface ChopTextOptions {
width?: number;
whiteSpace?: "normal" | "pre-wrap";
wordBreak?: "normal" | "keep-all";
locale?: string;
// Absolute pixels. CSS multipliers like 1.5 are not accepted by Pretext.
lineHeight?: number;
// Absolute pixels. Negative values follow CSS behavior.
letterSpacing?: number;
}DOM mode infers typography from the element. Pure mode needs the font passed in.
Selection
import { layoutText, select } from "@tonybonet/chop";
const result = layoutText("Chop splits real text", "700 40px Inter");
select(result.chars, { mode: "even" });
select(result.words, { mode: "first", count: 2 });
select(result.chars, {
mode: "grouped",
by: "word",
query: { mode: "last", count: 1 },
});API Litmus Test
Chop keeps one primary shape:
| Question | Answer |
| --- | --- |
| Animate words | gsap.from(chop(".hero"), vars) |
| Need another DOM unit | title.lines, title.chars, title.paragraphs |
| Need measurement only | layoutText(text, font, options) |
| Need a subset | select(result.chars, query) or native array methods |
There is no duplicate .words, no .elements(unit), and no wrapper object. The
array itself is the word list.
CSS Hooks
Projected elements receive stable data attributes and CSS custom properties.
| Unit | Attribute | Main index variable |
| --- | --- | --- |
| line | [data-chop-line] | --chop-line-index |
| word | [data-chop-word] | --chop-word-index |
| char | [data-chop-char] | --chop-char-index |
| paragraph | [data-chop-paragraph] | --chop-paragraph-index |
[data-chop-char] {
opacity: 0;
animation: fade-in 0.3s calc(var(--chop-char-index) * 40ms) both;
}React Pattern
For DOM projection, keep the Chop instance in a ref and clean it up from the ref callback.
import { chop, type ChopElements } from "@tonybonet/chop";
import { useCallback, useRef } from "react";
function Heading() {
const instance = useRef<ChopElements | null>(null);
const setNode = useCallback((node: HTMLHeadingElement | null) => {
instance.current?.destroy();
instance.current = node ? chop(node) : null;
}, []);
return <h1 ref={setNode}>Text with real layout</h1>;
}Do not put ChopElements in React state. It is an imperative DOM projection.
Migration From The Old API
| Old | New |
| --- | --- |
| chop(node, { by: ["word"] }).elements("word") | chop(".headline") |
| instance.elements("line") | title.lines |
| instance.elements("char") | title.chars |
| instance.elements("paragraph") | title.paragraphs |
| instance.meta() | title.meta |
| instance.refresh() | title.relayout() |
| chop(text, font, options) | layoutText(text, font, options) |
Accessibility
Chop keeps the source text in the DOM, makes it visually transparent, and mounts
an aria-hidden overlay for animation. Screen readers keep reading the original
semantic text.
License
MIT.
