kugiri README

The project's README from github.com/edoardolunardi/kugiri. As one markdown file: README.md.

kugiri (区切り)

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.

Install

npm install kugiri

Use

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();

With CSS only

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;
  }
}

A split is a snapshot

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 translated page

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.

API

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.

Hooks written on the DOM

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.

What a unit is

Content is classified by how it lays out, not by tag:

Caveats

Compared with GSAP SplitText

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.

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.

Demo

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.

Showcase

Sites that split their text with kugiri:

Built something with it? Open a pull request and add it here.

Development

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

kugiri (edoardolunardi/kugiri)

Searching Database…

262 ·GitHub
What is this?A small TypeScript library that splits text into lines, words and characters at exactly the breaks the browser already painted — including RTL text, hyphenation, overflow-wrap, floats and multi-column — so animated units match the real layout.
Is it an animation library like GSAP?No. It is a split engine and nothing more: it cuts the text and returns the units, and you animate them yourself with the Web Animations API, GSAP, Motion or a stylesheet.
How do I add it to a project?Install the npm package and call splitText(target) on your element. It ships as one ES2022 file with no dependencies, about 7.5 kB minified and gzipped.
What are the gotchas?A character split loses the kerning between letters (true of every splitter). revert() drops state or listeners added inside the target, and screen readers may read a character split letter by letter — give the target an aria-label and aria-hidden on the units.
What does a split leave behind in the DOM?Each unit gets data-line, data-word or data-char with its index, the same index as a --line/--word/--char custom property, and a mask wrapper if you asked for one; each line also carries translate=no, and the target gets data-split plus count properties.
Can a plain stylesheet run the whole reveal?Yes. Every unit's index is written as a custom property, so CSS can drive the whole reveal while the script only splits and sets one attribute; read --line or --char the same way to stagger lines or characters.
Why doesn't Google Translate rewrite a split target?Each split line carries translate=no: a page translator rewrites text lazily, a unit at a time, into a word the box was not measured for. Put the source markup back, let the translator rewrite it, then split again.
How big is the library?One file, ES2022, no dependencies: about 7.5 kB minified and gzipped (20.3 kB minified, 6.8 kB with brotli).
What are similar projects?pretext, classnames, velocity, drawably, GSAP, tween.js, anime, snabbdom
Size
1 MB
Stars
262
Age
1mo
Commits · 90 Days