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…

263 ·GitHub
Что это такое?Небольшая TypeScript-библиотека, которая разбивает текст на строки, слова и символы ровно по тем границам, которые браузер уже отрисовал — включая текст справа налево, переносы, overflow-wrap, плавающие элементы и многоколоночную вёрстку, — так что анимируемые единицы совпадают с реальной раскладкой.
Это библиотека для анимаций вроде GSAP?Нет. Это только движок разбиения и ничего больше: он режет текст и возвращает единицы, а анимировать их вы сами через Web Animations API, GSAP, Motion или CSS-стили.
Как добавить её в проект?Установите npm-пакет и вызовите splitText(target) на своём элементе. Библиотека поставляется одним файлом ES2022 без зависимостей, около 7,5 кБ в минифицированном и сжатом gzip виде.
Какие есть подводные камни?При разбиении на символы теряется кернинг между буквами (это верно для любого сплиттера). revert() удаляет состояние или обработчики, добавленные внутри target, а скринридеры могут читать разбиение на символы по одной букве — задайте target атрибут aria-label, а единицам aria-hidden.
Что остаётся в DOM после разбиения?Каждая единица получает data-line, data-word или data-char со своим индексом, тот же индекс записывается в CSS-переменную --line/--word/--char, а если попросили — ещё и обёртку-маску; каждая строка также несёт translate=no, а target получает data-split и свойства-счётчики.
Может ли обычный CSS-файл полностью запустить появление?Да. Индекс каждой единицы записан в CSS-переменную, поэтому появлением может управлять весь CSS, а скрипт только разбивает текст и ставит один атрибут; читайте --line или --char тем же способом, чтобы анимировать строки или символы со задержкой друг за другом.
Почему Google Translate не переписывает разбитый элемент?Каждая разбитая строка несёт translate=no: переводчик страницы переписывает текст лениво, по одной единице за раз, подставляя слово, под которое блок не был измерен. Верните исходную разметку, дайте переводчику её переписать, а затем разбейте текст снова.
Насколько велика библиотека?Один файл, ES2022, без зависимостей: около 7,5 кБ в минифицированном и сжатом gzip виде (20,3 кБ минифицированная, 6,8 кБ с brotli).
What are similar projects?pretext, classnames, velocity, drawably, GSAP, tween.js, anime, snabbdom
Size
1 MB
Stars
263
Age
1mo
Commits · 90 Days