The project's README from github.com/edoardolunardi/kugiri. As one markdown file: README.md.
Splits text into lines, words and characters exactly where the browser already broke it.
Demo: https://edoardolunardi.github.io/kugiri/
kugiri (区切り) is the point where one segment ends and the next begins. That is what this library finds: the line boxes the browser painted, the words and graphemes inside them, and nothing else. It cuts the DOM at those points and marks every unit so you can animate it, in CSS or in script. It never decides where a line should break; the browser did that already.
Range.getClientRects(), not predicted, so
text-wrap: balance, authored newlines, <br>, right-to-left text, scripts without spaces,
hyphenation, overflow-wrap, text-indent, floats, multi-column layout, vertical writing and a
target an ancestor scales all come out as painted. Words and characters are wrapped inside those
lines under text-wrap: nowrap, so their boxes can never move a wrap.data-line, data-word or data-char with its
index, the same index as a custom property, and a mask wrapper if you want one. Animate with the
Web Animations API, GSAP, Motion, or a stylesheet. A split is a snapshot of one layout: nothing
watches the viewport or splits again on its own, and that part is yours.npm install kugiri
import { splitText } from "kugiri";
const target = document.querySelector("h1");
const split = splitText(target, { type: ["lines"], mask: "lines" });
const reveal = split.lines.map((line, index) =>
line.animate([{ transform: "translateY(100%)", opacity: 0 }, { transform: "none", opacity: 1 }], {
duration: 1000,
delay: index * 100,
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
fill: "backwards", // hidden until its turn, at rest when done
})
);
// A mask clips at rest too (descenders, focus rings), so drop the clip once the reveal is over.
await Promise.all(reveal.map((animation) => animation.finished));
for (const mask of split.masks) {
mask.style.clipPath = "none";
}
// Later, to put the original markup back:
split.revert();
The split writes the index of every unit as a custom property, so a stylesheet can run the whole
reveal and the script only has to split and set one attribute. Read --line or --char the same way
to stagger lines or characters.
splitText(target, { type: ["words"] });
target.dataset.revealed = "";
[data-word] {
opacity: 0;
transform: translateY(0.6em);
}
[data-revealed] [data-word] {
animation: rise 0.8s cubic-bezier(0.23, 1, 0.32, 1) forwards;
animation-delay: calc(var(--word) * 30ms);
}
@keyframes rise {
to {
opacity: 1;
transform: none;
}
}
kugiri is a split engine and nothing more. A split is the layout the text had at the moment it ran. It does not watch the viewport, the fonts, the text or the container, and it never splits again on its own. When the box the text wraps in changes width, the lines it was cut into are no longer the lines the browser would paint, and it is your code that reverts and splits again.
None of this ships with the library. The example below is one way to do it, not an API: it watches the target's inline size, folds the stream of changes a drag produces into one split per frame, and ignores height changes, which move no wrap (a phone's address bar collapsing on scroll is one). The demo does the same for its fifty cases with a short timeout in place of the frame:
import { splitText } from "kugiri";
const target = document.querySelector("h1");
let split = splitText(target, { type: ["lines"] });
let width = target.clientWidth;
let frame = 0;
new ResizeObserver(() => {
if (target.clientWidth === width) {
return;
}
width = target.clientWidth;
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
split.revert();
split = splitText(target, { type: ["lines"] });
});
}).observe(target);
The same holds for anything else that moves a wrap. Split after the fonts the text is set in have
loaded (await document.fonts.ready), and split again after the text or its styles change. Whether
to replay the reveal after a resize or to show the units at rest is a choice the split leaves to
you; the demo splits again with no reveal, so the text stays responsive and nothing plays twice.
A page translator (Chrome's, Google Translate's) rewrites the text in place, lazily, as it scrolls
into view, and treats every box as a segment of its own: it would translate a split target a unit
at a time, out of its sentence, from the page's declared language whatever the unit now says, into
a word the box was not measured for. So every line the split writes carries translate="no", and
a translator leaves a split target as it is. Translating it is a text change like any other: put
the source markup back, let the translator rewrite it, split again. A translator sets the root's
lang to the language it translated into, and its rewrite lands as a burst of mutations inside
the target. The split's own writes are taken off the observer as they are made, or they would pass
for rewrites:
import { splitText } from "kugiri";
const target = document.querySelector("h1");
const source = target.innerHTML;
let split = splitText(target, { type: ["lines"] });
let timer = 0;
const rewrites = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
split = splitText(target, { type: ["lines"] });
rewrites.takeRecords();
}, 150);
});
rewrites.observe(target, { subtree: true, childList: true, characterData: true });
new MutationObserver(() => {
split.revert();
target.innerHTML = source;
rewrites.takeRecords();
}).observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] });
The source goes back rather than the split's own markup: a target split after the translation captured the translator's rewrite, which the translator would translate a second time, from the wrong language, if it came back as new content. The demo does the same for its fifty cases, and a target that scrolls into view under a translated page waits for the translator's rewrite before it splits at all.
splitText(target, options?)Splits target in place and returns a TextSplit.
| Option | Type | Default | What it does |
|---|---|---|---|
type |
("lines" | "words" | "chars")[] |
["lines"] |
The units to produce. Words and chars always sit inside lines, so lines comes with them. |
mask |
SplitLevel | SplitLevel[] | MaskReach |
none | The units that get a clipping wrapper (clip-path: inset(0), set inline) to slide out from under. Any levels, whatever the unit you animate. As an object, each level names how far its clip reaches past the box across the line ({ lines: ".25em" }), for the descenders and accents a tight leading leaves outside the line box. The clip stays until you clear it. |
ignore |
string |
none | A selector for elements to leave whole: never cut into, never wrapped, never a unit. |
classes |
{ lines?, words?, chars?, mask? } |
none | Class names to add to the units and masks, on top of the data attributes they always carry. |
splitText(targets, options?)Given an array, or any iterable of elements such as a NodeList, splits every target with the same
options and returns one TextSplit per distinct target, in the same order. Every target is planned
before any is written, so the whole page costs the one layout a single target does. A loop of
single splits forces one layout per target instead, since each split's reads land on the layout the
one before it changed; in a word or character split of an article that is a third of the time.
const splits = splitText(document.querySelectorAll("article p"), { type: ["words"] });
TextSplit| Field | Type | What it holds |
|---|---|---|
lines |
HTMLElement[] |
One block per painted line of text, in document order. |
words |
HTMLElement[] |
Inline-block units, one per word, punctuation attached. Empty unless type includes words or chars. |
chars |
HTMLElement[] |
Inline-block units, one per grapheme cluster. Empty unless type includes chars. |
masks |
HTMLElement[] |
The clip wrappers, if any. |
revert |
() => void |
Puts the original markup back. |
| Where | Attribute | Custom property |
|---|---|---|
| Each unit | data-line, data-word, data-char (index) |
--line, --word, --char (index) |
| Each mask | data-mask (index) |
|
| The target | data-split (the levels present) |
--lines, --words, --chars (counts) |
| Each line | translate="no" (see A translated page) |
Indexes count in document order, so calc(var(--word) * 30ms) is a stagger and
calc((var(--words) - var(--word)) * 30ms) a reversed one.
Content is classified by how it lays out, not by tag:
a, em, strong, spans, an inline custom element) are cut into. One that
wraps across lines is cloned per line, attributes included.p, li, headings, blockquote) are split inside themselves, so a list
keeps its markers and numbers.summary), custom elements
that are boxes of their own, and anything matching ignore. An inline-level piece rides along
inside its line and, unless it matches ignore, counts as one word. A block-level piece is not
text: it is left exactly where it is and is not a unit at all, so a table or a button row is never
revealed as if it were a line.::first-line style is restated on the first line block, so it survives the cut and reaches
the units inside it.::first-letter (a big initial, a floated drop cap) is restated on the glyph itself,
because no browser keeps applying the pseudo once the first line is a block of its own, and none
applies it inside a word or character unit. A floated drop cap is then a float like any other: it
sits in front of the first line block, unclipped and unanimated, and the lines flow around it.details body, display: none) is left exactly as it is.revert() restores the original markup, so state or listeners added inside the target between
split and revert are lost, and an inline element with an id is duplicated while it wraps across
lines.Intl.Segmenter for words and graphemes (Chrome 87, Safari 14.1, Firefox 125).
Without it, words fall back to whitespace and graphemes to code points, which breaks emoji
sequences apart.translate="no", since a
translator would rewrite each unit on its own into a word the box was not measured for. Put the
source markup back for the translator and split again once it has rewritten it; see
A translated page.aria-label with its text and aria-hidden on the units.::first-line without the text-transform it reports, then applies it once an
animation has touched the line. The split verifies a restated first line against the painted row
and drops what does not reproduce it, but a transformed ::first-line is the one case to check by
eye in Safari.GSAP's SplitText makes the same three kinds of unit, and the two are not exclusive: kugiri's units are elements, and GSAP animates them like any other. The difference is where the lines come from. What follows was read off SplitText 3.15.0, the current release in September 2026, and checked by splitting the same paragraphs with both in Chromium, WebKit and Firefox.
inline-block, then reads the words' bounding boxes and starts a new line
wherever a word's top is below the one before it. The lines it returns are the lines of the
wrapped text. kugiri measures the untouched text with Range.getClientRects(), cuts it with
Range.extractContents() at the breaks the browser had already painted, and keeps its words and
characters under text-wrap: nowrap, so no box can move a wrap.overflow-wrap, moves whole to the next line once it is a box. A four-line paragraph with
hyphens: auto came back from SplitText as five lines, a line taller; a long URL under
overflow-wrap: anywhere came back as one box overflowing its column. kugiri returns the four
lines and restates the hyphen the browser drew.Intl.Segmenter and reads the breaks off the paint.reduceWhiteSpace, on by default), so authored newlines under
white-space: pre-line become spaces and three lines become one. kugiri leaves whitespace to
the browser.deepSlice, per word, each a forced layout. kugiri reads everything first and
writes everything after, across every target passed together.What SplitText does that kugiri does not: split again on resize and on font load (autoSplit),
set aria-label on the target and aria-hidden on the units, take a custom word delimiter, a
prepareText hook and a specialChars list. kugiri is a snapshot of one layout and leaves those
to the caller. SplitText is also the smaller file, 3.7 kB gzipped to kugiri's 7.5, and it runs
without the GSAP core: the file imports nothing, and register() borrows toArray and context
from a core when one is on the page and falls back to its own otherwise. kugiri spends the
difference on the list above. SplitText is under the GSAP standard license, kugiri under MIT.
The demo is live at https://edoardolunardi.github.io/kugiri/.
npm run dev opens the demo, which is also the test suite: fifty cases, each split as it
scrolls into view. Check lines compares every split with the lines the browser painted before
the split, text and geometry. Masks outlines every mask the split made, one around every unit,
so you can see what it produced. The page reveals with the Web Animations API and reverts and
splits every case again when its column changes width, with no reveal a second time, the way a
consumer has to; its header shows the code for both, and for the same reveal in CSS only, as examples
that are not part of the library.
npm test runs the same check headless in Chromium, WebKit and Firefox.
Sites that split their text with kugiri:
Built something with it? Open a pull request and add it here.
npm install # also installs the git hooks
npm run dev # the demo on http://localhost:4173
npm run check # types and lint
npm run format # Biome, with fixes
npm test # Playwright against the demo
npm run build # dist/, from tsc
npm run build.demo # dist-demo/, the static demo published to GitHub Pages
npm run release -- patch|minor|major # changelog, size, version, tag, push; CI publishes
Commits follow Conventional Commits and are checked by a hook. A release is one command: it rolls
the changelog's Unreleased section into a dated version, restates the size above, bumps, tags and
pushes; the tag then runs a workflow that publishes to npm through trusted publishing and lists
the GitHub release. Try it with --dry-run first. Instructions for agents live in AGENTS.md
and .agents/skills/.
A pull request follows the template in .github/PULL_REQUEST_TEMPLATE.md: why, what changed, the
demo case that shows it, and a checklist. A workflow checks the