Skip to content

Documentation

Chop is the small layer between real browser text and animation code. It measures with Pretext, projects animation handles, and leaves the source DOM in place for accessibility.

Terminal window
pnpm add @tonybonet/chop
import { chop } from '@tonybonet/chop'
import gsap from 'gsap'
const words = chop('.headline')
gsap.from(words, {
y: 32,
opacity: 0,
stagger: 0.05,
ease: 'power3.out',
})

The returned value is already the word array. That keeps the common case short and lets animation libraries consume the result directly.

const title = chop('.headline')
title // word elements
title.lines // lazy line elements
title.chars // lazy grapheme elements
title.paragraphs // lazy paragraph elements
title.meta // layout counts and dimensions

Words project immediately because they are the default animation unit. Lines, characters, and paragraphs project on first access.

UnitUse it when
WordsYou want fast headline staggers with minimal DOM projection.
LinesThe animation needs real rendered line breaks.
CharactersYou need grapheme-aware motion for emoji, accents, symbols, or mixed scripts.
ParagraphsYou want section-level reveals without reparsing blank-line boundaries.

The playground is built to exercise the API surface, not just look pretty.

DemoWhat to inspect
WordsDefault projection, replay behavior, and GSAP stagger timing.
LinesResolved line handles on real wrapping text.
CharsEmoji, punctuation, accents, and non-Latin samples without manual slicing.
SplitUnit switching and editable text without stale overlays.
Composeselect() queries like middle characters, ranges, and grouped selections.
MetricsBrowser-derived x-height, cap-height, ascender, and descender data.
PowerSplit cost, animation cost, and heavier DOM projection scenarios.

Mount from a callback ref. Destroy the previous instance before creating the next one so React owns the source DOM and Chop owns only its overlay.

import { chop, type ChopElements } from '@tonybonet/chop'
import { useCallback, useRef } from 'react'
function Heading() {
const instanceRef = useRef<ChopElements | null>(null)
const setTextNode = useCallback((node: HTMLHeadingElement | null) => {
instanceRef.current?.destroy()
instanceRef.current = node ? chop(node) : null
}, [])
return <h1 ref={setTextNode}>Text with real layout</h1>
}

Keep ChopElements in a ref, not component state. It is an imperative DOM projection, so state just adds rerenders without clarifying ownership.

Use layoutText() when a renderer should own the markup.

import { defineFont, layoutText, select } from '@tonybonet/chop'
const result = layoutText(
'Slice cleanly across scripts: Hola, 東京, emoji 👋',
defineFont('700 48px Inter'),
{ width: 520 },
)
const middle = select(result.chars, { mode: 'middle', count: 7 })

Pure mode returns measured handles, not DOM nodes. It still needs a browser-like canvas environment because Pretext measures text.

Chop keeps the source DOM readable and owns only the overlay. When text, fonts, or container width change, call relayout() before replaying animation.

const title = chop('.headline')
await document.fonts.ready
title.relayout()

Lifecycle summary:

MethodEffect
chop()Creates one aria-hidden overlay and projects words.
relayout()Recomputes handles and updates the overlay in place.
destroy()Removes the overlay and restores source styles.

The Power demo separates split work from animation work.

TrackWhat it proves
Chop lazyWords can animate without projecting lines or characters up front.
Chop fullWord, character, line, and paragraph handles are available when needed.
Manual DOMRaw DOM can be fast, but ownership and escaping are yours.
GSAP SplitTextA mutation-based reference point for production animation workflows.

Read the benchmark as a workflow comparison, not a universal scoreboard. Different text, fonts, container widths, and animation effects will move the numbers.

Before adding API, check the shape:

QuestionGood answer
Common word animationgsap.from(chop('.hero'), options)
Lines, chars, or paragraphstitle.lines, title.chars, title.paragraphs
Filteringtitle.filter((_, i) => i % 2 === 0)
Pure measurementlayoutText(text, font)

No by, no elements(), no duplicate .words. The array is already the words.

OldNew
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)layoutText(text, font)
  • Single line breaks stay inside one paragraph. Blank lines create new paragraph handles.
  • Locale-sensitive transforms infer lang from the element unless you pass locale explicitly.
  • destroy() removes the overlay and allows the same target to mount again.
  • GitHub Pages deploys with GITHUB_PAGES=true so Astro emits URLs under /chop.