rtl-text-transform 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +529 -2
- package/dist/chunk-QDWUKRPS.js +1853 -0
- package/dist/chunk-QDWUKRPS.js.map +1 -0
- package/dist/index.cjs +1867 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +261 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +1951 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +37 -0
- package/dist/react.d.ts +37 -0
- package/dist/react.js +84 -0
- package/dist/react.js.map +1 -0
- package/package.json +104 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rtl-text-transform contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,530 @@
|
|
|
1
|
-
#
|
|
1
|
+
# rtl-text-transform
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/rtl-text-transform)
|
|
4
|
+
[](https://github.com/ehsania/rtl-text-transform/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](./package.json)
|
|
7
|
+
|
|
8
|
+
[Live demo](https://ehsania.github.io/rtl-text-transform/) · [Playground](https://ehsania.github.io/rtl-text-transform/playground/) · [npm](https://www.npmjs.com/package/rtl-text-transform)
|
|
9
|
+
|
|
10
|
+
A tiny, framework-agnostic TypeScript library for text morphs that preserve Arabic-script shaping, Persian joins, ZWNJ and ligatures.
|
|
11
|
+
|
|
12
|
+
Two effects share one clock and compose freely:
|
|
13
|
+
|
|
14
|
+
- **Goo** dissolves complete text layers into each other with per-layer blur, opacity and an SVG alpha threshold.
|
|
15
|
+
- **Rise** keeps matched runs static and crisp while changed hunks travel, scale and blur into place.
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
morph progress = easeInOutCubic(elapsed / morphTime)
|
|
19
|
+
|
|
20
|
+
goo blur = min(blurEm × fontSizePx × (1 / fraction − 1), 100px)
|
|
21
|
+
goo opacity = fraction ^ 0.4
|
|
22
|
+
goo alpha = 255 × sourceAlpha − threshold
|
|
23
|
+
|
|
24
|
+
rise p = easeOutCubic(local progress)
|
|
25
|
+
rise offset = travel × (1 − p) along flow
|
|
26
|
+
rise scale = 1 − (1 − scale) × (1 − p)
|
|
27
|
+
rise blur = blurEm × (1 − p)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The library uses zero runtime dependencies. It atomizes text with `Intl.Segmenter`, Unicode joining-form logic and a small relationship diff — not HarfBuzz or a font shaper. The browser still shapes every rendered run, so joins, contextual forms, ZWNJ and the common lam-alef ligature are preserved. See [Diff and shaping tradeoffs](#diff-and-shaping-tradeoffs).
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- Two independently toggleable effects: `goo` and `rise`.
|
|
35
|
+
- All four combinations work: goo only, rise only, both, or a plain fade.
|
|
36
|
+
- Only changed hunks rise; matched text stays crisp and glides with FLIP.
|
|
37
|
+
- Ease-in-out morph progress and ease-out hunk motion by default.
|
|
38
|
+
- Separate easing curves for the overall morph and rise hunks, with a zero-dependency cubic-bezier helper.
|
|
39
|
+
- One shared `morphTime`; rise stagger is scaled to fit inside it.
|
|
40
|
+
- Zero runtime dependencies.
|
|
41
|
+
- Vanilla core plus optional React hook and component.
|
|
42
|
+
- Content and motion presets, with per-hunk flow resolvers.
|
|
43
|
+
- ESM + CJS + TypeScript declarations.
|
|
44
|
+
- No DOM access at import time.
|
|
45
|
+
- One `requestAnimationFrame` loop per instance, paused when hidden or offscreen.
|
|
46
|
+
- Respects `prefers-reduced-motion`.
|
|
47
|
+
- Reference-counted SVG filters with deterministic cleanup.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npm install rtl-text-transform
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
React is optional and only needed for the `./react` entry point:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npm install react
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { createMorph } from "rtl-text-transform";
|
|
65
|
+
|
|
66
|
+
const el = document.querySelector<HTMLElement>("#morph");
|
|
67
|
+
if (!el) throw new Error("Missing #morph");
|
|
68
|
+
|
|
69
|
+
const morph = createMorph(el, {
|
|
70
|
+
words: ["کتاب", "کتابی", "کتابها", "کتابهایم"],
|
|
71
|
+
dir: "rtl",
|
|
72
|
+
lang: "fa",
|
|
73
|
+
morphTime: 1,
|
|
74
|
+
restTime: 0.25,
|
|
75
|
+
effects: {
|
|
76
|
+
goo: { blur: 0.075, threshold: 140 },
|
|
77
|
+
rise: { travel: 0.3, stagger: 35 },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Later:
|
|
82
|
+
morph.setText("سلام");
|
|
83
|
+
morph.pause();
|
|
84
|
+
morph.play();
|
|
85
|
+
morph.destroy();
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The library default is **goo on, rise off**:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const gooOnly = createMorph(el, { words });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
To compose both effects explicitly:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const combined = createMorph(el, {
|
|
98
|
+
words,
|
|
99
|
+
effects: {
|
|
100
|
+
goo: true,
|
|
101
|
+
rise: {
|
|
102
|
+
blur: 0.1,
|
|
103
|
+
travel: 0.3,
|
|
104
|
+
scale: 0.94,
|
|
105
|
+
stagger: 35,
|
|
106
|
+
overlap: 0.15,
|
|
107
|
+
flow: "up",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The host element supplies typography. Set `font-family`, `font-size`, `font-weight` and `color` on it:
|
|
114
|
+
|
|
115
|
+
```html
|
|
116
|
+
<div
|
|
117
|
+
id="morph"
|
|
118
|
+
dir="rtl"
|
|
119
|
+
lang="fa"
|
|
120
|
+
style="font-family: Vazirmatn, system-ui, sans-serif; font-size: 104px; font-weight: 800"
|
|
121
|
+
>
|
|
122
|
+
کتاب
|
|
123
|
+
</div>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The library inherits those values and forces `letter-spacing: 0` on its layers. Existing host children are replaced while the morph is alive and restored on `destroy()`. The element is treated as a dedicated container.
|
|
127
|
+
|
|
128
|
+
### Sizing and filter clipping
|
|
129
|
+
|
|
130
|
+
An SVG filter has a filter region. Arabic dots, ascenders and descenders can sit well outside the em box, especially at heavy weights.
|
|
131
|
+
|
|
132
|
+
The library:
|
|
133
|
+
|
|
134
|
+
- sets `min-height: 1.6em` only when the computed min-height is still zero/auto;
|
|
135
|
+
- turns a computed `display: inline` host into a block with `width: 100%`, because absolute layers need a width;
|
|
136
|
+
- uses a generous filter region (`-25% -35% 150% 170%`);
|
|
137
|
+
- leaves `overflow` untouched.
|
|
138
|
+
|
|
139
|
+
For very tall marks, long rise travel or unusual fonts, add breathing room to the host:
|
|
140
|
+
|
|
141
|
+
```css
|
|
142
|
+
#morph {
|
|
143
|
+
min-height: 2em;
|
|
144
|
+
padding: 0.2em 0.1em;
|
|
145
|
+
overflow: visible;
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Do not clip the host with `overflow: hidden` if glyphs or rise motion extend outside its box. The stage around it may clip; the host itself should not.
|
|
150
|
+
|
|
151
|
+
## Presets
|
|
152
|
+
|
|
153
|
+
Presets are plain option bundles. The quickest setup is a content preset plus a motion preset:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { createMorph } from "rtl-text-transform";
|
|
157
|
+
|
|
158
|
+
const morph = createMorph(el, {
|
|
159
|
+
preset: "numbers",
|
|
160
|
+
motion: "default",
|
|
161
|
+
words: ["۳", "۴"],
|
|
162
|
+
dir: "rtl",
|
|
163
|
+
lang: "fa",
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Presets never set the content itself. Pass `words`, `dir`, `lang`, `autoplay` and callbacks yourself.
|
|
168
|
+
|
|
169
|
+
### Content presets
|
|
170
|
+
|
|
171
|
+
| Preset | Effects | Flow | Values |
|
|
172
|
+
| ----------------- | ---------- | ---------------- | ------------------------------------------------------------------------------------------ |
|
|
173
|
+
| `numbers` | goo + rise | numeric auto | goo `blur 0.1`, `threshold 70`, `atRest false`; rise `blur 0.1`, `travel 0.6`, `scale 0.9` |
|
|
174
|
+
| `wordsHorizontal` | goo + rise | per changed hunk | goo `0.1 / 70 / false`; rise `blur 0.04`, `travel 0.24`, `scale 0.96` |
|
|
175
|
+
| `wordsUp` | goo + rise | `up` | same word values |
|
|
176
|
+
| `wordsDown` | goo + rise | `down` | same word values |
|
|
177
|
+
|
|
178
|
+
`numbers` compares the first numeric value: increase → `up`, decrease → `down`, equal or unparseable → `up`. Persian, Arabic-Indic and Latin digits are supported.
|
|
179
|
+
|
|
180
|
+
For a rise-only number morph, override the effect branch:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
createMorph(el, {
|
|
184
|
+
preset: "numbers",
|
|
185
|
+
motion: "default",
|
|
186
|
+
effects: { goo: false },
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Motion presets
|
|
191
|
+
|
|
192
|
+
| Motion | `morphTime` | Rise easing | `stagger` | `overlap` |
|
|
193
|
+
| --------- | ----------- | -------------------- | --------- | --------- |
|
|
194
|
+
| `default` | `0.3` | `easeOutCubic` | `70ms` | `0` |
|
|
195
|
+
| `smooth` | `0.8` | smooth decelerating | `30ms` | `0.18` |
|
|
196
|
+
| `snappy` | `0.2` | sharper decelerating | `50ms` | `0` |
|
|
197
|
+
| `gentle` | `1.1` | slow in-out | `45ms` | `0.2` |
|
|
198
|
+
|
|
199
|
+
When `preset` is supplied and `motion` is omitted, `motion: "default"` is used.
|
|
200
|
+
|
|
201
|
+
### Fine-tuning
|
|
202
|
+
|
|
203
|
+
Inline options always win. Nested effect objects merge:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
createMorph(el, {
|
|
207
|
+
preset: "numbers",
|
|
208
|
+
motion: "gentle",
|
|
209
|
+
effects: {
|
|
210
|
+
rise: { blur: 0.14, travel: 0.75 },
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Use `false` to disable an effect branch. Use `true` to re-enable it with the library defaults.
|
|
216
|
+
|
|
217
|
+
The resolved preset values are inspectable:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import { motions, presets, resolvePreset } from "rtl-text-transform";
|
|
221
|
+
|
|
222
|
+
presets.numbers;
|
|
223
|
+
motions.snappy;
|
|
224
|
+
console.log(resolvePreset("numbers", { motion: "snappy" }));
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Per-hunk flow
|
|
228
|
+
|
|
229
|
+
`effects.rise.flow` accepts a fixed direction or a resolver:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
interface RiseFlowContext {
|
|
233
|
+
from: string;
|
|
234
|
+
to: string;
|
|
235
|
+
dir: "rtl" | "ltr" | "auto";
|
|
236
|
+
rtl: boolean;
|
|
237
|
+
hunk: {
|
|
238
|
+
id: string;
|
|
239
|
+
kind: "add" | "remove";
|
|
240
|
+
text: string;
|
|
241
|
+
start: number;
|
|
242
|
+
end: number;
|
|
243
|
+
length: number;
|
|
244
|
+
position: number; // 0 at the start, 1 at the end
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The resolver runs once per changed hunk when a morph starts, so a word can have a prefix hunk move one way and a suffix hunk move the other. The exported `flowByHunkPosition` and `flowByNumberChange` helpers are the functions used by the word and number presets.
|
|
250
|
+
|
|
251
|
+
`wordsHorizontal` moves each hunk toward the edge it belongs to. RTL maps start → `right` and end → `left`; LTR maps start → `left` and end → `right`. An exactly centred hunk uses the end side.
|
|
252
|
+
|
|
253
|
+
Presets are resolved at creation and whenever `update()` receives `preset` or `motion`. Inline options in the same call are applied after the preset.
|
|
254
|
+
|
|
255
|
+
## Options
|
|
256
|
+
|
|
257
|
+
`createMorph(el, options?)` accepts:
|
|
258
|
+
|
|
259
|
+
| Option | Type | Default | Description |
|
|
260
|
+
| -------------- | -------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
261
|
+
| `words` | `string[]` | `[]` | Optional auto-cycling list. The first entry is shown immediately. |
|
|
262
|
+
| `preset` | `PresetName` | — | Content preset: `numbers`, `wordsHorizontal`, `wordsUp`, `wordsDown`. |
|
|
263
|
+
| `motion` | `MotionName` | `"default"` with a preset | Motion preset: `default`, `smooth`, `snappy`, `gentle`. |
|
|
264
|
+
| `morphTime` | `number` | `1` | Length of one morph in seconds. `0` applies instantly. |
|
|
265
|
+
| `restTime` | `number` | `0.25` | Rest after a morph before the next automatic one, in seconds. |
|
|
266
|
+
| `effects` | `MorphEffects` | `{ goo: true }` | Nested effect options. Goo defaults on; rise defaults off. |
|
|
267
|
+
| `dir` | `"rtl" \| "ltr" \| "auto"` | host value | Sets `dir` on the container. |
|
|
268
|
+
| `lang` | `string` | host value | Sets `lang` on the container. |
|
|
269
|
+
| `autoplay` | `boolean` | `true` | Start cycling `words` automatically. |
|
|
270
|
+
| `loop` | `boolean` | `true` | Wrap from the last word to the first. When false, auto-cycling stops at the last word. |
|
|
271
|
+
| `easing` | `(t: number) => number` | `easeInOutCubic` | Timing curve for overall morph progress and matched-text FLIP. Pass `t => t` for linear. Must return 0–1. |
|
|
272
|
+
| `onMorphStart` | `(from, to) => void` | — | Called when a morph starts, including the morph that follows a fast-forward. |
|
|
273
|
+
| `onMorphEnd` | `(from, to) => void` | — | Called when a morph reaches its target. |
|
|
274
|
+
|
|
275
|
+
`words` is rest-first, matching the original demo: rest on the current word, then morph to the next.
|
|
276
|
+
|
|
277
|
+
### `effects`
|
|
278
|
+
|
|
279
|
+
Each effect accepts `boolean` or an options object. An object enables the effect and merges with that effect's defaults.
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
interface MorphEffects {
|
|
283
|
+
goo?: boolean | GooEffectOptions;
|
|
284
|
+
rise?: boolean | RiseEffectOptions;
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
#### Goo options
|
|
289
|
+
|
|
290
|
+
| Option | Type | Default | Description |
|
|
291
|
+
| ----------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------- |
|
|
292
|
+
| `blur` | `number` | `0.075` | Layer blur strength in `em`, scaled by the host's computed font size. |
|
|
293
|
+
| `threshold` | `number` | `140` | The `T` in the alpha row `0 0 0 255 -T`. Clamped to 0–255. |
|
|
294
|
+
| `atRest` | `boolean` | `true` | Apply the goo CSS filter while the text is at rest. When false, the filter is only applied during a morph. |
|
|
295
|
+
|
|
296
|
+
#### Rise options
|
|
297
|
+
|
|
298
|
+
| Option | Type | Default | Description |
|
|
299
|
+
| ------------------- | ----------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
300
|
+
| `blur` | `number` | `0.1` | Motion blur at the start of an entering hunk, in `em`. |
|
|
301
|
+
| `travel` | `number` | `0.3` | Vertical travel in `em`. |
|
|
302
|
+
| `scale` | `number` | `0.94` | Scale at the start of an entering hunk. Clamped to 0–1. |
|
|
303
|
+
| `stagger` | `number` | `35` | Requested delay between neighbouring hunks, in milliseconds. Scaled down when needed to fit `morphTime`. |
|
|
304
|
+
| `overlap` | `number` | `0.15` | Fraction of the entering window that overlaps the outgoing hunk. Clamped to 0–1. |
|
|
305
|
+
| `easing` | `(t: number) => number` | `easeOutCubic` | Timing curve for each hunk's enter and exit progress. Must return 0–1. |
|
|
306
|
+
| `flow` | `RiseFlowValue` | `"up"` | Travel direction. A fixed direction, or a resolver called once per changed hunk. `up`: outgoing hunks leave upward and new hunks arrive from below. `left`: outgoing hunks leave left and new hunks arrive from the right. `down` and `right` reverse those directions. |
|
|
307
|
+
| `minChunk` | `number` | `2` | Matched runs shorter than this are treated as changed unless they are a whole word. |
|
|
308
|
+
| `normalizeVariants` | `boolean` | `true` | Treat Arabic/Persian kaf and yeh variants as equal while diffing. |
|
|
309
|
+
| `ignoreDiacritics` | `boolean` | `true` | Do not let combining marks prevent a match. Rendered text still keeps the marks. |
|
|
310
|
+
|
|
311
|
+
`normalizeVariants` and `ignoreDiacritics` affect diff equality only. They never rewrite the text that the browser shapes or renders.
|
|
312
|
+
|
|
313
|
+
## Effect combinations
|
|
314
|
+
|
|
315
|
+
| `goo` | `rise` | Behavior |
|
|
316
|
+
| ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
317
|
+
| `true` | `false` | Two full text layers blur and cross-fade through the SVG threshold. The host gets `url(#…) blur(0.6px)` for a softer threshold edge. |
|
|
318
|
+
| `false` | `true` | Changed hunks rise, scale and motion-blur. Matched chunks stay static and crisp. No SVG filter is created. |
|
|
319
|
+
| `true` | `true` | Changed hunks receive **both** the goo blur curve and rise motion blur. Matched chunks stay crisp. The host keeps the threshold but omits the extra `blur(0.6px)` softener. |
|
|
320
|
+
| `false` | `false` | A short opacity cross-fade. |
|
|
321
|
+
|
|
322
|
+
Rise transitions omit the extra host softener so matched chunks stay crisp.
|
|
323
|
+
|
|
324
|
+
### Motion and easing
|
|
325
|
+
|
|
326
|
+
Motion is not linear by default:
|
|
327
|
+
|
|
328
|
+
- The overall morph, goo progress and matched FLIP use **ease-in-out cubic**. The dissolve starts gently, accelerates through the middle and settles at the end.
|
|
329
|
+
- Changed rise hunks use **ease-out cubic**. They start moving immediately and settle into place instead of arriving at a constant speed.
|
|
330
|
+
- `easing` controls the overall morph and matched FLIP. `effects.rise.easing` controls hunk motion independently.
|
|
331
|
+
- The package exports `linear`, `easeOutCubic`, `easeInOutCubic` and `cubicBezier` so the same curves can be inspected or reused.
|
|
332
|
+
- Custom easing functions must return a value from 0 to 1. Reduced motion replaces the goo/rise motion with a short opacity cross-fade.
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { createMorph, cubicBezier, easeOutCubic } from "rtl-text-transform";
|
|
336
|
+
|
|
337
|
+
createMorph(el, {
|
|
338
|
+
easing: cubicBezier(0.65, 0, 0.35, 1),
|
|
339
|
+
effects: {
|
|
340
|
+
rise: {
|
|
341
|
+
easing: easeOutCubic,
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Interruptions
|
|
348
|
+
|
|
349
|
+
- **Goo-only:** a new target starts immediately from the dominant current visual state, without a jump.
|
|
350
|
+
- **Rise or combined:** the running morph fast-forwards in roughly `0.12s` (or one frame for short morphs), then the latest queued target starts. Intermediate queued targets are dropped; their promises resolve.
|
|
351
|
+
|
|
352
|
+
`setText()` never disables automatic cycling. Call `pause()` or `update({ autoplay: false })` if a custom string should stay put.
|
|
353
|
+
|
|
354
|
+
## API
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
interface Morph {
|
|
358
|
+
setText(text: string): Promise<void>;
|
|
359
|
+
next(): Promise<void>;
|
|
360
|
+
setWords(words: string[]): void;
|
|
361
|
+
play(): void;
|
|
362
|
+
pause(): void;
|
|
363
|
+
destroy(): void;
|
|
364
|
+
update(partialOptions: Partial<MorphOptions>): void;
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
- **`setText(text)`** morphs from the current visual state. If a goo-only morph is running, the winning layer keeps its exact current opacity and blur, so the new morph does not jump. A rise or combined morph fast-forwards first.
|
|
369
|
+
- **`next()`** advances one entry in `words`. It returns a resolved promise when there is nowhere to go (no words, or `loop: false` at the end).
|
|
370
|
+
- **`setWords(words)`** replaces the list. The current reference text is kept if it is still in the list; otherwise the index resets to zero.
|
|
371
|
+
- **`play() / pause()`** resume or freeze the animation clock and automatic cycling. While paused, `setText()` and `next()` apply immediately instead of animating, so calls never hang.
|
|
372
|
+
- **`update(partial)`** changes options in place. Nested effect patches merge with current values, so `update({ effects: { rise: { stagger: 60 } } })` keeps the other rise settings. Changing the goo threshold releases and re-acquires the shared SVG filter.
|
|
373
|
+
- **`destroy()`** cancels the loop, removes listeners and the filter reference, removes the layers and live region, and restores the host's original children, `dir`/`lang`, and touched inline styles. Safe to call twice.
|
|
374
|
+
|
|
375
|
+
## React
|
|
376
|
+
|
|
377
|
+
The React entry point is thin and optional. React is a peer dependency.
|
|
378
|
+
|
|
379
|
+
### Component
|
|
380
|
+
|
|
381
|
+
```tsx
|
|
382
|
+
import { MorphText } from "rtl-text-transform/react";
|
|
383
|
+
|
|
384
|
+
export function Hero() {
|
|
385
|
+
return (
|
|
386
|
+
<MorphText
|
|
387
|
+
words={["کتاب", "کتابی", "کتابها", "کتابهایم"]}
|
|
388
|
+
dir="rtl"
|
|
389
|
+
lang="fa"
|
|
390
|
+
morphTime={1}
|
|
391
|
+
restTime={0.25}
|
|
392
|
+
effects={{
|
|
393
|
+
goo: { blur: 0.075, threshold: 140 },
|
|
394
|
+
rise: { travel: 0.3, stagger: 35 },
|
|
395
|
+
}}
|
|
396
|
+
style={{
|
|
397
|
+
fontFamily: "Vazirmatn, system-ui, sans-serif",
|
|
398
|
+
fontSize: "clamp(48px, 13vw, 148px)",
|
|
399
|
+
fontWeight: 800,
|
|
400
|
+
}}
|
|
401
|
+
/>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
`MorphText` uses `div` by default; pass `as="span"` or another element type. Do not pass children: use `text` for a controlled string or `words` for a cycling list. `text` changes call `setText()`.
|
|
407
|
+
|
|
408
|
+
Presets are forwarded as props too:
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
<MorphText preset="numbers" motion="snappy" words={["۳", "۴"]} dir="rtl" lang="fa" />
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Hook
|
|
415
|
+
|
|
416
|
+
```tsx
|
|
417
|
+
import { useMorph } from "rtl-text-transform/react";
|
|
418
|
+
|
|
419
|
+
function Morph() {
|
|
420
|
+
const { ref, morph } = useMorph({
|
|
421
|
+
words: ["سلام", "درود", "خوش آمدید"],
|
|
422
|
+
dir: "rtl",
|
|
423
|
+
lang: "fa",
|
|
424
|
+
effects: { goo: true, rise: true },
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<>
|
|
429
|
+
<span ref={ref} style={{ fontSize: 96, fontWeight: 700 }} />
|
|
430
|
+
<button onClick={() => morph?.next()}>Next</button>
|
|
431
|
+
</>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Options are forwarded to `update()` without recreating the instance. The hook also returns `setText`, `next`, `setWords`, `play` and `pause` bound to the current instance.
|
|
437
|
+
|
|
438
|
+
## Arabic, Persian and RTL notes
|
|
439
|
+
|
|
440
|
+
- **Rendered shaping is preserved.** The browser shapes each rendered chunk. The diff only decides which source ranges stay together.
|
|
441
|
+
- **Contextual forms are compared.** An atom key includes Unicode joining forms, so a letter that changes from final to medial is not treated as unchanged.
|
|
442
|
+
- **Lam-alef is one atom.** The common Persian/Arabic lam-alef relationship stays in one rendered span.
|
|
443
|
+
- **ZWNJ breaks joining.** Persian compounds such as `میخواهم` keep their ZWNJ and their final/isolated forms; changed hunks do not span the break.
|
|
444
|
+
- **ZWNJ does not block an otherwise identical shape.** If a letter has the same contextual form on both sides of a ZWNJ, the diff matches it; the invisible join control alone never forces it to animate.
|
|
445
|
+
- **ZWJ padding preserves chunk edges.** When a chunk boundary falls inside a joined run, a zero-width joiner forces the correct contextual form at the edge so the chunk still connects visually.
|
|
446
|
+
- **Letter-spacing is forced to zero** on layers and chunks with `!important`. Positive tracking disconnects Arabic joins.
|
|
447
|
+
- **Set `dir` and `lang`.** Pass `dir: "rtl"` and `lang: "fa"` or `"ar"` (or set them on the host). `dir: "auto"` lets the browser decide from the first strong character.
|
|
448
|
+
- **Font weight matters for goo.** The alpha threshold rounds pixel coverage to on/off. Thin strokes, dots and small diacritics can erode. Try weight 600–800, a lower threshold (110–140), or less blur.
|
|
449
|
+
- **Do not use positive `word-spacing`** for the same reason as letter-spacing. The library leaves `word-spacing` inherited.
|
|
450
|
+
- **Avoid transform scaling** on the host during a morph. The computed font size is sampled for blur and travel, and CSS transforms do not change it.
|
|
451
|
+
|
|
452
|
+
## Diff and shaping tradeoffs
|
|
453
|
+
|
|
454
|
+
This library deliberately avoids a font-shaping dependency such as HarfBuzz.
|
|
455
|
+
|
|
456
|
+
Atomization uses:
|
|
457
|
+
|
|
458
|
+
1. `Intl.Segmenter` grapheme clusters (with a combining-mark fallback);
|
|
459
|
+
2. Unicode-based joining-form signatures derived from neighbouring letters;
|
|
460
|
+
3. lam-alef grouping for the common Arabic/Persian relationship;
|
|
461
|
+
4. a longest-common-subsequence atom diff with optional kaf/yeh and diacritic normalization;
|
|
462
|
+
5. changed-hunk construction that splits after whitespace but keeps ZWNJ compounds together;
|
|
463
|
+
6. ZWJ padding at internal chunk edges.
|
|
464
|
+
|
|
465
|
+
The consequence: a font-specific ligature that is not identifiable from Unicode alone cannot always be predicted. Rare ligatures may animate as changed text instead of staying fixed. Common Arabic/Persian shaping, joins, ZWNJ and lam-alef are handled. This is the cost of staying fully zero-dependency.
|
|
466
|
+
|
|
467
|
+
## Accessibility
|
|
468
|
+
|
|
469
|
+
- Both animated layers are `aria-hidden="true"`.
|
|
470
|
+
- One visually hidden copy of the current target text stays in the container with `aria-live="polite"` and `aria-atomic="true"`.
|
|
471
|
+
- The live copy updates when a morph starts, so screen readers hear the new word once.
|
|
472
|
+
- `prefers-reduced-motion: reduce` switches to a short opacity cross-fade (max 0.18s), with no blur, no rise and no SVG threshold.
|
|
473
|
+
- `pause()` freezes the clock; `play()` resumes it.
|
|
474
|
+
|
|
475
|
+
## SSR
|
|
476
|
+
|
|
477
|
+
Importing the package is safe on the server: no DOM access happens at module load. `createMorph` and the React hook must run in a browser after mount. The React component has no server-rendered text; use the vanilla container plus a `<noscript>` fallback if the text must be visible before hydration.
|
|
478
|
+
|
|
479
|
+
## Known limitations
|
|
480
|
+
|
|
481
|
+
- **Thin strokes and dots in goo mode.** The threshold is a hard alpha cutoff. At low font weights, Arabic dots and fine diacritics can vanish. The effect works best on heavy display weights. `effects.goo.atRest: false` keeps the crisp original at rest.
|
|
482
|
+
- **Rise needs a usable diff.** Completely unrelated strings have no matched chunks, so everything rises together. Gradual phrase changes show the effect best.
|
|
483
|
+
- **Font-specific ligatures.** See [Diff and shaping tradeoffs](#diff-and-shaping-tradeoffs).
|
|
484
|
+
- **One host filter.** If the host already has a CSS `filter`, the library overrides it while active and restores the inline value on `destroy()`.
|
|
485
|
+
- **`filter` scale is not accounted for.** Blur uses the computed font size in CSS pixels. A parent `transform: scale()` scales the result but not the blur amount.
|
|
486
|
+
- **Long strings do not wrap.** Layers use `white-space: nowrap`. Size the host for the longest phrase, or shorten the list.
|
|
487
|
+
- **Blur is capped at 100px**, exactly as in the original goo demo.
|
|
488
|
+
- **Rest-first timing.** Auto-cycling waits `restTime` before the first morph and after every completed morph.
|
|
489
|
+
- **No font loading management.** Use `font-display` and wait for `document.fonts.ready` if the morph must begin after a webfont loads.
|
|
490
|
+
|
|
491
|
+
## Development
|
|
492
|
+
|
|
493
|
+
```sh
|
|
494
|
+
npm install
|
|
495
|
+
npm run dev # landing at http://localhost:5173/ and playground at /playground/
|
|
496
|
+
npm test # Vitest
|
|
497
|
+
npm run typecheck # tsc --noEmit
|
|
498
|
+
npm run lint # ESLint
|
|
499
|
+
npm run format # Prettier
|
|
500
|
+
npm run build # typecheck + tsup (ESM/CJS/d.ts) + Vite site build
|
|
501
|
+
npm run build:lib # package only
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
The source layout is:
|
|
505
|
+
|
|
506
|
+
```text
|
|
507
|
+
src/core/options.ts public types, defaults and patch merging
|
|
508
|
+
src/core/easing.ts cubic-bezier solver and default motion curves
|
|
509
|
+
src/core/atoms.ts grapheme atoms, joining forms, lam-alef
|
|
510
|
+
src/core/diff.ts zero-dependency LCS atom diff
|
|
511
|
+
src/core/chunks.ts changed-hunk construction and min-chunk merging
|
|
512
|
+
src/core/effects/rise.ts rise timing and transforms
|
|
513
|
+
src/core/transition.ts transition state, easing helpers and per-hunk flow
|
|
514
|
+
src/core/scene.ts shaped chunk rendering, ZWJ padding, FLIP
|
|
515
|
+
src/core/layers.ts layer shells, inner lines and live region
|
|
516
|
+
src/core/controller.ts state machine, rAF, effect composition, interruption
|
|
517
|
+
src/core/filter.ts reference-counted shared SVG filters
|
|
518
|
+
src/core/math.ts shared goo math, unit-tested
|
|
519
|
+
src/index.ts vanilla entry
|
|
520
|
+
src/react.tsx hook + component wrapper
|
|
521
|
+
demo/ landing page + playground, rebuilt on the library
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
## Changelog
|
|
525
|
+
|
|
526
|
+
See [CHANGELOG.md](./CHANGELOG.md).
|
|
527
|
+
|
|
528
|
+
## License
|
|
529
|
+
|
|
530
|
+
MIT
|