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 或者样式表。
怎么把它加到项目里?安装这个 npm 包,然后对你的元素调用 splitText(target) 就行。它只有一个 ES2022 文件,没有任何依赖,压缩并 gzip 后大约 7.5 kB。
有什么需要注意的坑?按字符拆分会丢失字母之间的字距(所有拆分工具都一样)。revert() 会丢掉你在目标元素内部添加的状态或监听器,而且屏幕阅读器可能会把按字符拆分的文本一个字母一个字母地读出来——所以请给目标元素加上 aria-label,并给各个单位加上 aria-hidden。
拆分之后 DOM 里会留下什么?每个单位都会带上 data-line、data-word 或 data-char 属性,值是它的序号,同时还有一个相同序号的 --line/--word/--char 自定义属性;如果你要求了,还会有一个 mask 包裹层;每一行还会带上 translate=no;目标元素则会加上 data-split 以及几个计数属性。
纯 CSS 样式表能完成整个显现动画吗?可以。每个单位的序号都写成了自定义属性,所以整个显现动画可以完全由 CSS 驱动,脚本只负责拆分并设置一个属性;用同样的方式读取 --line 或 --char,就能让各行或各字符依次错开出现。
为什么 Google Translate 不会改写拆分过的元素?因为每个拆分出来的行都带有 translate=no:页面翻译器是惰性逐个单位改写文本的,会把文本变成一个尺寸没有量过的词。解决办法是先恢复原始标记,让翻译器改写完,然后再拆分一次。
这个库有多大?只有一个文件,ES2022,零依赖:压缩并 gzip 后大约 7.5 kB(仅压缩是 20.3 kB,用 brotli 则是 6.8 kB)。
What are similar projects?pretext, classnames, velocity, drawably, GSAP, tween.js, anime, snabbdom
Size
1 MB
Stars
263
Age
1mo
Commits · 90 Days