diff --git a/README.ko.md b/README.ko.md
index 096f8e1..e550ee1 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -159,13 +159,60 @@ Responsive hero title, section heading, 짧은 product copy처럼 실제 시각
`overlayText()`는 rendered line frame에 적용되는 built-in style-transform effect(`fade`, `rise`, `slide`, `shake`, `scale`, `pulse`)도 받습니다. 내부적으로 `lineReveal()`은 opacity를 line frame에 적용하고, vertical reveal offset만 line mask 안의 paragraph copy에 적용합니다. 그래서 `lineReveal().and(scale())` 또는 `pulse()` 조합은 measured line center를 기준으로 커지며 renderer 자신의 line mask에 잘리지 않습니다.
+## Typewriter
+
+짧은 인사말, 온보딩 문구, 라벨을 한 글자씩 보여주면서 완성 문장의 공간과 줄바꿈을 유지하고 싶다면 `typewriter()`를 사용하세요. 패키지 root에서 가져오는 옵션 없는 effect이며, preset이나 별도 renderer가 아닙니다.
+
+```tsx
+import {
+ defineTextMotion,
+ graphemes,
+ nativeText,
+ parentLabelPolicy,
+ stagger,
+ typewriter,
+ useTextMotionControls,
+} from '@react-native-motion-kit/text-motion';
+import { Button } from 'react-native';
+
+const TypewriterText = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText())
+ .timeline(stagger(0.04))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 0 } })
+ .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' }))
+ .component();
+
+export function Greeting() {
+ const controls = useTextMotionControls();
+
+ return (
+ <>
+ 안녕 세계! 👩🏽💻
+
+ >
+ );
+}
+```
+
+`stagger(0.04)`의 단위는 초이므로 보이는 글자가 40ms 간격으로 시작합니다. 첫 글자는 첫 playback update에서 나타납니다. 공백과 명시적인 줄바꿈은 자리를 유지하되 delay slot을 소비하지 않고, 문장부호는 일반 글자처럼 slot을 하나 소비합니다. 실제 표시 시각은 기기의 프레임에 맞춰집니다. `duration: 0`은 autoplay와 controls 재생에서 각 글자를 즉시 드러냅니다.
+
+기존 controls로 replay, reset, stop을 실행할 수 있습니다. `stop()`은 현재 상태를 유지하지만, 이후 `play()`가 남은 타이핑 일정을 정확히 이어 주는 것은 아닙니다. 다른 문장을 처음부터 모두 다시 타이핑하려면 React `key`를 바꿔 remount하세요. 텍스트 append는 streaming API가 아닙니다.
+
+외부 재생에는 `controls` 대신 `progress={progress}`를 전달합니다. 해당 instance에서는 `.motion()`을 실행하지 않습니다. **Progress는 표시된 글자의 비율이 아니라 effect timeline의 진행률입니다.** 보이는 글자 10개에 `stagger(0.05)`를 쓰면 progress가 약 `0.310345`를 넘을 때 모두 드러나므로, `0.5`가 다섯 글자를 뜻하지 않습니다. Progress를 줄이면 글자가 다시 숨겨질 수 있습니다. 실제 시간상 일정한 간격으로 드러내려면 외부 timing curve를 linear로 설정하세요.
+
+`typewriter()`에 `rise()`나 `fade()`를 조합할 수도 있습니다. 각 글자는 local progress가 양수가 될 때까지 숨겨지고, 시작한 뒤에는 다른 effect가 계속 적용됩니다. `fade()`를 더하면 의도적으로 서서히 나타납니다. 조합 순서를 바꿔도 이 표시 규칙은 유지됩니다. 글자가 나타난 뒤 움직이는 모습까지 보이려면 `.motion()`에 0보다 큰 duration을 지정하세요.
+
+이 effect는 `nativeText()`만 지원합니다. 해당 renderer가 만든 완성 token layout을 유지하며, 단일 `Text`와 같은 조판·줄바꿈, 커서, 실제 문자열의 글자별 추가·삭제, streaming은 제공하지 않습니다. 내장 grapheme fallback은 이제 `한` 같은 분해 한글 음절을 원문 정규화 없이 한 단위로 보존하지만, Unicode 전체 grapheme 규칙을 구현한 것은 아닙니다. 복잡한 script에는 앱이 제공하는 `Intl.Segmenter`나 `custom()` splitter가 필요할 수 있습니다. 긴 문장이나 많은 row는 target device에서 먼저 성능을 측정하세요.
+
## Stable MVP Scope
- recipe API: `defineTextMotion().split().layout().timeline().effect().component()`
- splitters: `graphemes`, `words`, `custom`, experimental newline-only `lines`
- renderers: `nativeText`, `overlayText`
- timelines: `stagger`, `sequence`, `parallel`, `wave`
-- effects: `fade`, `rise`, `slide`, `scale`, `pulse`, `shake`, `lineReveal`
+- effects: `fade`, `rise`, `slide`, `scale`, `pulse`, `shake`, `lineReveal`, `typewriter`
- presets subpath: `@react-native-motion-kit/text-motion/presets`
- event-driven play/replay/reset/stop을 위한 명시적인 playback `controls`
- external Reanimated shared value 기반 controlled progress
@@ -198,7 +245,7 @@ Skia, rich text, RN-rendered line-to-token mapping은 deferred 상태입니다.
`text-motion`은 split text motion에 집중합니다. 즉 tokenization, layout-aware timing, effect, renderer, split token playback, accessibility, 그리고 title, label, onboarding copy, product copy를 위한 preset이 핵심입니다.
-Text token 또는 text layout에 의존하는 기능은 이 패키지에서 고려할 수 있습니다. 예를 들면 typewriter, scramble, wipe, text-change transition, mask나 word-in-line effect 같은 향후 rendered-line extension, 그리고 명시적인 capability boundary 뒤에 있는 renderer-specific effect가 있습니다.
+Text token 또는 text layout에 의존하는 기능은 이 패키지에서 고려할 수 있습니다. 예를 들면 scramble, wipe, text-change transition, mask나 word-in-line effect 같은 향후 rendered-line extension, 그리고 명시적인 capability boundary 뒤에 있는 renderer-specific effect가 있습니다.
다른 문제를 푸는 기능은 별도 package가 더 적합할 가능성이 큽니다. Number count-up, odometer, currency, percentage, timer, delta animation은 split-text layout 문제가 아니라 value formatting 문제입니다. 나중에 별도의 value-motion 계열 package가 Motion Kit convention을 공유할 수 있지만, 그 때문에 `text-motion` API surface를 넓히지는 않습니다.
@@ -242,6 +289,7 @@ corepack pnpm run example:build
- title, label, onboarding copy, product copy처럼 짧은 React Native 텍스트에 entrance motion을 넣고 싶을 때
- Reanimated 기반 word-level 또는 grapheme-level reveal effect가 필요할 때
+- 완성 문장의 배치를 유지하면서 한 글자씩 드러나는 Typewriter가 필요할 때
- 일회성 animation prop보다 재사용 가능한 motion recipe를 만들고 싶을 때
- screen reader가 split token을 하나씩 읽지 않고 문장을 한 번만 읽어야 할 때
- button, screen focus, onboarding step에서 remount 없이 replay/reset/stop을 실행하고 싶을 때
diff --git a/README.md b/README.md
index 3de7f72..8319169 100644
--- a/README.md
+++ b/README.md
@@ -159,13 +159,60 @@ Use this for responsive hero titles, section headings, and concise product copy
`overlayText()` also accepts the built-in style-transform effects (`fade`, `rise`, `slide`, `shake`, `scale`, and `pulse`) because they operate on the rendered line frame. `lineReveal()` is split internally: its opacity is applied to the line frame, while its vertical reveal offset moves only the paragraph copy inside the line mask. This lets combinations such as `lineReveal().and(scale())` and `pulse()` grow from the measured line center without being clipped by the renderer's own line mask.
+## Typewriter
+
+Use `typewriter()` for short greetings, onboarding copy, or labels that should appear one character at a time while keeping the completed phrase's space and line breaks in place. It is an effect imported from the package root, with no options; it is not a preset or a separate renderer.
+
+```tsx
+import {
+ defineTextMotion,
+ graphemes,
+ nativeText,
+ parentLabelPolicy,
+ stagger,
+ typewriter,
+ useTextMotionControls,
+} from '@react-native-motion-kit/text-motion';
+import { Button } from 'react-native';
+
+const TypewriterText = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText())
+ .timeline(stagger(0.04))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 0 } })
+ .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' }))
+ .component();
+
+export function Greeting() {
+ const controls = useTextMotionControls();
+
+ return (
+ <>
+ 안녕 세계! 👩🏽💻
+
+ >
+ );
+}
+```
+
+`stagger(0.04)` uses seconds, so visible characters start 40ms apart. The first character appears on the first playback update; whitespace and hard newlines keep their layout space without taking a delay slot, and punctuation takes a slot like any other character. Actual display times follow device frames. `duration: 0` makes the default reveal instant for each character in autoplay and controls-driven playback.
+
+Use the existing controls to replay, reset, or stop typing. `stop()` keeps the current state; `play()` afterward does not promise an exact continuation of the remaining typing schedule. To type a different phrase completely from the start, remount it with a new React `key`. Appending text is not a streaming API.
+
+For external playback, pass `progress={progress}` instead of `controls`. `.motion()` is ignored for that instance. **Progress describes the effect timeline, not the percentage of characters shown:** with ten visible characters and `stagger(0.05)`, all characters are shown once progress exceeds about `0.310345`, so `0.5` does not mean five characters. Decreasing progress can hide characters again. Use a linear external timing curve when you want evenly spaced threshold crossings in time.
+
+`typewriter()` can combine with `rise()` or `fade()`. Each character stays hidden until its local progress becomes positive, then the other effects continue normally; adding `fade()` intentionally makes its appearance gradual. The visibility rule works in either composition order. For a visible movement after each character appears, use a nonzero `.motion()` duration.
+
+This effect supports `nativeText()` only. It preserves that renderer's completed token layout; it does not provide exact single-`Text` shaping or line wrapping, a cursor, growing/deleting the underlying string, or streaming. The built-in grapheme fallback now keeps decomposed Hangul syllable sequences such as `한` together without normalizing the source, but it does not implement all Unicode grapheme rules. Complex scripts can still need an app-provided `Intl.Segmenter` or `custom()` splitter. Measure long text and many rows on your target devices before treating them as supported workloads.
+
## Stable MVP Scope
- recipe API: `defineTextMotion().split().layout().timeline().effect().component()`
- splitters: `graphemes`, `words`, `custom`, experimental newline-only `lines`
- renderers: `nativeText`, `overlayText`
- timelines: `stagger`, `sequence`, `parallel`, `wave`
-- effects: `fade`, `rise`, `slide`, `scale`, `pulse`, `shake`, `lineReveal`
+- effects: `fade`, `rise`, `slide`, `scale`, `pulse`, `shake`, `lineReveal`, `typewriter`
- presets subpath: `@react-native-motion-kit/text-motion/presets`
- explicit playback `controls` for event-driven play/replay/reset/stop
- controlled progress via external Reanimated shared values
@@ -198,7 +245,7 @@ Skia, rich text, and RN-rendered line-to-token mapping are deferred. Context/pro
`text-motion` stays focused on split text motion: tokenization, layout-aware timing, effects, renderers, split-token playback, accessibility, and presets for titles, labels, onboarding copy, and product copy.
-Features that depend on text tokens or text layout can be considered here, including typewriter, scramble, wipe, text-change transitions, future rendered-line extensions such as masks or word-in-line effects, and renderer-specific effects behind explicit capability boundaries.
+Features that depend on text tokens or text layout can be considered here, including scramble, wipe, text-change transitions, future rendered-line extensions such as masks or word-in-line effects, and renderer-specific effects behind explicit capability boundaries.
Features that solve a different problem are likely better served by separate package candidates. Number count-up, odometer, currency, percentage, timer, and delta animations are value-formatting problems rather than split-text layout problems. A separate value-motion package could share Motion Kit conventions without expanding the `text-motion` API surface.
@@ -242,6 +289,7 @@ Use this package today when you need:
- entrance motion for short React Native text such as titles, labels, onboarding copy, or product copy
- word-level or grapheme-level reveal effects powered by Reanimated
+- character-by-character Typewriter reveals that keep the completed phrase's layout
- reusable motion recipes instead of one-off animation props
- accessible split text that screen readers read as one phrase
- event-driven playback controls for replay/reset/stop without remounting
diff --git a/example/src/App.tsx b/example/src/App.tsx
index aa14e4b..39daf8f 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -8,6 +8,7 @@ import {
nativeText,
overlayText,
parallel,
+ parentLabelPolicy,
pulse,
rise,
scale,
@@ -15,6 +16,7 @@ import {
shake,
slide,
stagger,
+ typewriter,
useTextMotionControls,
type TextMotionComponent,
wave,
@@ -149,6 +151,17 @@ const ShakeEffect = defineTextMotion()
.effect(shake({ x: 8 }).and(fade()))
.component();
+const TypewriterText = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText())
+ .timeline(stagger(0.04))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 0 } })
+ .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' }))
+ .component();
+
+const typewriterDemoText = '안녕, \u1112\u1161\u11ab글! 👩🏽💻\n자리를 지키며 써요.';
+
const StaggerTimeline = defineTextMotion()
.split(words())
.layout(nativeText())
@@ -244,6 +257,15 @@ const RendererProbeScaleGraphemeText = defineTextMotion()
.motion({ kind: 'timing', options: { duration: 240 } })
.component();
+const RendererProbeFadeGraphemeText = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText())
+ .timeline(stagger(0.04))
+ .effect(fade())
+ .motion({ kind: 'timing', options: { duration: 0 } })
+ .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' }))
+ .component();
+
const RendererProbeProgressText = defineTextMotion()
.split(words())
.layout(nativeText())
@@ -405,6 +427,28 @@ const rendererProbeCases: readonly RendererProbeCase[] = [
text: rendererProbeGraphemeText,
threshold: 'caution',
},
+ {
+ Component: RendererProbeFadeGraphemeText,
+ effectProfile: 'fade / 0ms timing / 40ms stagger',
+ id: 'graphemes-54-fade-controls',
+ label: 'Graphemes 54 instant fade',
+ motionTokenCount: 54,
+ playbackMode: 'controls',
+ splitLabel: 'graphemes()',
+ text: rendererProbeGraphemeText,
+ threshold: 'caution',
+ },
+ {
+ Component: TypewriterText,
+ effectProfile: 'typewriter / 0ms timing / 40ms stagger',
+ id: 'graphemes-54-typewriter-controls',
+ label: 'Graphemes 54 typewriter',
+ motionTokenCount: 54,
+ playbackMode: 'controls',
+ splitLabel: 'graphemes()',
+ text: rendererProbeGraphemeText,
+ threshold: 'caution',
+ },
{
Component: RendererProbeScaleGraphemeText,
effectProfile: 'scale + fade',
@@ -416,6 +460,17 @@ const rendererProbeCases: readonly RendererProbeCase[] = [
text: rendererProbeHeavyGraphemeText,
threshold: 'stress',
},
+ {
+ Component: TypewriterText,
+ effectProfile: 'typewriter / 0ms timing / 40ms stagger',
+ id: 'graphemes-130-typewriter-controls',
+ label: 'Graphemes 130 typewriter',
+ motionTokenCount: 130,
+ playbackMode: 'controls',
+ splitLabel: 'graphemes()',
+ text: rendererProbeHeavyGraphemeText,
+ threshold: 'stress',
+ },
{
Component: RendererProbeRiseWordsText,
effectProfile: 'rise + fade',
@@ -770,6 +825,14 @@ const demos: readonly Demo[] = [
text: 'Custom token boundaries stay flexible',
title: 'Custom',
},
+ {
+ Component: TypewriterText,
+ caption: 'typewriter() / 40ms per letter / fixed layout',
+ groupId: 'effects',
+ id: 'typewriter',
+ text: typewriterDemoText,
+ title: 'Typewriter',
+ },
{
Component: FadeEffect,
caption: 'fade()',
@@ -879,6 +942,14 @@ const demos: readonly Demo[] = [
text: 'Controls replay without remounting',
title: 'Playback Controls',
},
+ {
+ Component: TypewriterText,
+ caption: 'typewriter() + controls={controls}',
+ groupId: 'playback',
+ id: 'typewriter-controls',
+ text: typewriterDemoText,
+ title: 'Typewriter Controls',
+ },
{
caption: 'manual fan-out check',
groupId: 'playback',
@@ -1154,7 +1225,15 @@ function createStableSignatureLines(
}));
}
-function ControlsPlaybackDemo({ replaySignal, text }: { replaySignal: number; text: string }) {
+function ControlsPlaybackDemo({
+ Component = ControlsPlaybackText,
+ replaySignal,
+ text,
+}: {
+ Component?: TextMotionComponent;
+ replaySignal: number;
+ text: string;
+}) {
const controls = useTextMotionControls();
const replaySignalRef = useRef(replaySignal);
@@ -1169,9 +1248,9 @@ function ControlsPlaybackDemo({ replaySignal, text }: { replaySignal: number; te
return (
-
+
{text}
-
+
{controlsDemoSelected ? (
-
+
) : controlsStressDemoSelected ? (
) : rendererPerformanceProbeSelected ? (
diff --git a/packages/text-motion/README.ko.md b/packages/text-motion/README.ko.md
index 5bf0ea6..07137fb 100644
--- a/packages/text-motion/README.ko.md
+++ b/packages/text-motion/README.ko.md
@@ -283,6 +283,7 @@ scale({ from: 0.92, to: 1 });
pulse({ scale: 1.08 });
shake({ x: 6 });
lineReveal({ y: 18 });
+typewriter();
```
Effect는 `.and(...)`로 조합할 수 있습니다.
@@ -299,6 +300,70 @@ Built-in effect의 숫자 입력은 finite number여야 합니다. `rise({ y })`
`lineReveal()`은 `overlayText()`가 제공하는 `line-mask` capability가 필요합니다. `nativeText()`와는 호환되지 않습니다.
+## Typewriter
+
+짧은 인사말, 온보딩 문구, 라벨을 한 글자씩 보여주면서 완성 문장의 공간과 줄바꿈을 유지하고 싶다면 `typewriter()`를 사용하세요. 패키지 root에서 가져오는 옵션 없는 effect이며, preset이나 별도 renderer가 아닙니다.
+
+```tsx
+import {
+ defineTextMotion,
+ graphemes,
+ nativeText,
+ parentLabelPolicy,
+ stagger,
+ typewriter,
+ useTextMotionControls,
+} from '@react-native-motion-kit/text-motion';
+import { Button } from 'react-native';
+
+const TypewriterText = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText())
+ .timeline(stagger(0.04))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 0 } })
+ .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' }))
+ .component();
+
+export function Greeting() {
+ const controls = useTextMotionControls();
+
+ return (
+ <>
+ 안녕 세계! 👩🏽💻
+
+ >
+ );
+}
+```
+
+`stagger(0.04)`의 단위는 초이므로 보이는 글자가 40ms 간격으로 시작합니다. 첫 글자는 첫 playback update에서 나타납니다. 공백과 명시적인 줄바꿈은 자리를 유지하되 delay slot을 소비하지 않고, 문장부호는 일반 글자처럼 slot을 하나 소비합니다. 실제 표시 시각은 기기의 프레임에 맞춰집니다. `duration: 0`은 autoplay와 controls 재생에서 각 글자를 즉시 드러냅니다.
+
+기존 controls로 replay, reset, stop을 실행할 수 있습니다. `stop()`은 현재 상태를 유지하지만, 이후 `play()`가 남은 타이핑 일정을 정확히 이어 주는 것은 아닙니다. 다른 문장을 처음부터 모두 다시 타이핑하려면 React `key`를 바꿔 remount하세요. 텍스트 append는 streaming API가 아닙니다.
+
+외부 재생에는 `controls` 대신 `progress={progress}`를 전달합니다. 해당 instance에서는 `.motion()`을 실행하지 않습니다. **Progress는 표시된 글자의 비율이 아니라 effect timeline의 진행률입니다.** 보이는 글자 10개에 `stagger(0.05)`를 쓰면 progress가 약 `0.310345`를 넘을 때 모두 드러나므로, `0.5`가 다섯 글자를 뜻하지 않습니다. Progress를 줄이면 글자가 다시 숨겨질 수 있습니다. 실제 시간상 일정한 간격으로 드러내려면 외부 timing curve를 linear로 설정하세요.
+
+`typewriter()`에 `rise()`나 `fade()`를 조합할 수도 있습니다. 각 글자는 local progress가 양수가 될 때까지 숨겨지고, 시작한 뒤에는 다른 effect가 계속 적용됩니다. `fade()`를 더하면 의도적으로 서서히 나타납니다. 조합 순서를 바꿔도 이 표시 규칙은 유지됩니다. 글자가 나타난 뒤 움직이는 모습까지 보이려면 `.motion()`에 0보다 큰 duration을 지정하세요.
+
+이 effect는 `nativeText()`만 지원합니다. 해당 renderer가 만든 완성 token layout을 유지하며, 단일 `Text`와 같은 조판·줄바꿈, 커서, 실제 문자열의 글자별 추가·삭제, streaming은 제공하지 않습니다. 내장 grapheme fallback은 이제 `한` 같은 분해 한글 음절을 원문 정규화 없이 한 단위로 보존하지만, Unicode 전체 grapheme 규칙을 구현한 것은 아닙니다. 복잡한 script에는 앱이 제공하는 `Intl.Segmenter`나 `custom()` splitter가 필요할 수 있습니다. 긴 문장이나 많은 row는 target device에서 먼저 성능을 측정하세요.
+
+### 등장 방식과 motion 선택
+
+`typewriter()`는 splitter를 바꾸거나 타이핑 속도를 소유하지 않습니다. 글자별로 보여 주려면 `graphemes()`, 단어별로 보여 주려면 `words()`를 선택하세요. Timeline은 시작 간격을 정하고, `.motion()`은 autoplay 또는 controls mode에서 각 token의 부가 motion을 정합니다. Stagger origin을 바꾸거나 `wave()`를 사용하면 해당 timeline 순서로 나타나며, 앞에서부터 타이핑하는 순서를 보장하지 않습니다.
+
+예를 들어 다음 effect와 motion을 사용하면 글자가 나타난 뒤 120ms 동안 위로 움직입니다.
+
+```tsx
+.effect(typewriter().and(rise({ y: 6 })))
+.motion({ kind: 'timing', options: { duration: 120 } })
+```
+
+`fade()`와 조합하면 opacity animation은 그대로 적용되면서 시작 전의 글자는 숨겨집니다. `typewriter()`를 두 번 조합해도 표시 동작이나 playback clock이 하나 더 생기지 않습니다.
+
+외부 `progress`에서 `0`은 animated 글자를 숨기고 `1`은 최종 상태를 렌더링합니다. 기존 mapping은 token span `1.0`을 사용합니다. Token delay가 `d`, 가장 큰 delay가 `D`라면 local progress는 `clamp(progress * (D + 1) - d, 0, 1)`이며, 이 값이 양수인 글자를 보여 줍니다. 최대 progress를 기억하지 않으므로 각 글자의 시작 지점 이전으로 되돌리면 다시 숨겨집니다. Spring이 `1`을 overshoot하는 것만으로 이미 나타난 글자가 숨겨지지는 않고, 해당 글자의 시작 threshold를 역방향으로 지나야 합니다.
+
+예제의 `parentLabelPolicy({ reducedMotion: 'final-state' })`는 reduced motion이 켜지면 완성 문장을 보여 줍니다. 외부 progress가 아직 `0`인 instance에도 적용됩니다. Screen reader에는 부모의 전체 문장이 한 번 노출되고 장식용 글자 node는 숨겨지며, 글자마다 읽기 알림을 보내지 않습니다.
+
## Native Renderer Contract
`nativeText()`는 stable MVP renderer입니다. Animated token은 React Native에서 `rise()`, `slide()`, `scale()`, `pulse()` 같은 transform effect가 실제로 보이도록 animated `View` container와 내부 `Text`로 렌더링됩니다. Static token은 그대로 plain `Text`로 렌더링됩니다.
@@ -519,6 +584,8 @@ const ReducedReveal = defineTextMotion()
React Native/Hermes 환경에서는 `Intl.Segmenter`가 항상 보장되지 않습니다. 내장 splitter는 먼저 런타임의 `Intl.Segmenter`를 감지해서 사용하고, 없으면 작은 내부 tokenizer로 fallback합니다. 그래서 앱 시작 시 animation이 crash하지 않습니다.
+Grapheme fallback은 `한` 같은 분해 한글 음절과 완성형 한글·자모가 이어지는 음절 경계를 원문 정규화나 변경 없이 보존합니다. Unicode 전체 grapheme 규칙을 구현한 것은 아니므로 `क्ष` 같은 일부 인도계 결합 문자는 여전히 나뉠 수 있습니다. Token별 렌더링도 단일 native `Text`의 조판이나 ligature를 보장하지는 않습니다.
+
이 fallback은 UI motion의 안정성을 위한 것이지, full ICU 수준의 locale segmentation을 목표로 하지 않습니다. 정확한 locale-aware word boundary가 필요한 앱, 특히 복잡한 word breaking이 필요한 script를 다루는 앱은 app entrypoint에서 `@formatjs/intl-segmenter` 같은 polyfill을 로드하거나 제품에 맞는 `custom()` splitter를 사용해야 합니다.
## Stable MVP
@@ -528,7 +595,7 @@ React Native/Hermes 환경에서는 `Intl.Segmenter`가 항상 보장되지 않
- experimental newline-only `lines()`
- `nativeText()`, `overlayText()`
- `stagger()`, `sequence()`, `parallel()`, `wave()`
-- `fade()`, `rise()`, `slide()`, `scale()`, `pulse()`, `shake()`, `lineReveal()`
+- `fade()`, `rise()`, `slide()`, `scale()`, `pulse()`, `shake()`, `lineReveal()`, `typewriter()`
- 명시적인 `controls` prop과 `useTextMotionControls()` 기반 play/replay/reset/stop
- external Reanimated shared value 기반 controlled `progress`
- hidden animated token node를 사용하는 parent-label accessibility policy
@@ -543,7 +610,6 @@ Custom effect factory와 renderer capability factory는 MVP root API에서 의
- custom effect factory API
- renderer capability factory API
- `wipe`
-- `typewriter`
- `scramble`
- Skia renderer 또는 Skia-only effects
- `pause`, `seek`, `reverse` 같은 추가 playback API
@@ -574,7 +640,7 @@ Core text-motion 작업은 다음에 집중해야 합니다.
Advanced text effect는 여전히 text token 또는 text layout에 의존할 때 고려할 수 있습니다.
-- typewriter, scramble, wipe, highlight sweep effect
+- scramble, wipe, highlight sweep effect
- source range 정책이 안정된 뒤 다룰 수 있는 word-in-line effect와 RN-rendered line-to-token mapping
- old/new token set의 playback policy가 필요한 text-change transition
@@ -599,6 +665,7 @@ Renderer-specific effect는 renderer capability boundary 뒤에 둬야 합니다
- hero title, section title, 짧은 label, onboarding copy, product description
- `fade`, `rise`, `slide`, `scale`, `pulse`, `shake`를 조합한 word/grapheme entrance motion
+- 완성 문장의 배치를 유지하면서 한 글자씩 드러나는 Typewriter
- preset이나 `defineTextMotion()`으로 재사용 가능한 recipe component를 만들고 싶은 경우
- component를 remount하지 않고 replay/reset/stop을 실행해야 하는 경우
- external Reanimated shared value로 text motion progress를 직접 제어해야 하는 경우
diff --git a/packages/text-motion/README.md b/packages/text-motion/README.md
index f98565d..9955afd 100644
--- a/packages/text-motion/README.md
+++ b/packages/text-motion/README.md
@@ -282,6 +282,7 @@ scale({ from: 0.92, to: 1 });
pulse({ scale: 1.08 });
shake({ x: 6 });
lineReveal({ y: 18 });
+typewriter();
```
Effects compose with `.and(...)`:
@@ -298,6 +299,70 @@ Numeric built-in effect inputs must be finite numbers. Negative offsets are allo
`lineReveal()` requires the `line-mask` capability from `overlayText()`. It is not compatible with `nativeText()`.
+## Typewriter
+
+Use `typewriter()` for short greetings, onboarding copy, or labels that should appear one character at a time while keeping the completed phrase's space and line breaks in place. It is an effect imported from the package root, with no options; it is not a preset or a separate renderer.
+
+```tsx
+import {
+ defineTextMotion,
+ graphemes,
+ nativeText,
+ parentLabelPolicy,
+ stagger,
+ typewriter,
+ useTextMotionControls,
+} from '@react-native-motion-kit/text-motion';
+import { Button } from 'react-native';
+
+const TypewriterText = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText())
+ .timeline(stagger(0.04))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 0 } })
+ .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' }))
+ .component();
+
+export function Greeting() {
+ const controls = useTextMotionControls();
+
+ return (
+ <>
+ 안녕 세계! 👩🏽💻
+
+ >
+ );
+}
+```
+
+`stagger(0.04)` uses seconds, so visible characters start 40ms apart. The first character appears on the first playback update; whitespace and hard newlines keep their layout space without taking a delay slot, and punctuation takes a slot like any other character. Actual display times follow device frames. `duration: 0` makes the default reveal instant for each character in autoplay and controls-driven playback.
+
+Use the existing controls to replay, reset, or stop typing. `stop()` keeps the current state; `play()` afterward does not promise an exact continuation of the remaining typing schedule. To type a different phrase completely from the start, remount it with a new React `key`. Appending text is not a streaming API.
+
+For external playback, pass `progress={progress}` instead of `controls`. `.motion()` is ignored for that instance. **Progress describes the effect timeline, not the percentage of characters shown:** with ten visible characters and `stagger(0.05)`, all characters are shown once progress exceeds about `0.310345`, so `0.5` does not mean five characters. Decreasing progress can hide characters again. Use a linear external timing curve when you want evenly spaced threshold crossings in time.
+
+`typewriter()` can combine with `rise()` or `fade()`. Each character stays hidden until its local progress becomes positive, then the other effects continue normally; adding `fade()` intentionally makes its appearance gradual. The visibility rule works in either composition order. For a visible movement after each character appears, use a nonzero `.motion()` duration.
+
+This effect supports `nativeText()` only. It preserves that renderer's completed token layout; it does not provide exact single-`Text` shaping or line wrapping, a cursor, growing/deleting the underlying string, or streaming. The built-in grapheme fallback now keeps decomposed Hangul syllable sequences such as `한` together without normalizing the source, but it does not implement all Unicode grapheme rules. Complex scripts can still need an app-provided `Intl.Segmenter` or `custom()` splitter. Measure long text and many rows on your target devices before treating them as supported workloads.
+
+### Choosing the reveal and motion
+
+`typewriter()` does not choose the splitter or own typing speed. Keep `graphemes()` for character-by-character motion, or use `words()` when whole words should appear together. The timeline owns the start delays, and `.motion()` owns each token's optional motion in autoplay or controls mode. A different stagger origin or `wave()` follows that timeline's order rather than promising start-to-end typing.
+
+For example, this effect and motion pair makes each character appear and then rise for 120ms:
+
+```tsx
+.effect(typewriter().and(rise({ y: 6 })))
+.motion({ kind: 'timing', options: { duration: 120 } })
+```
+
+Combining with `fade()` preserves its opacity animation, while Typewriter still prevents characters from showing before they start. Adding `typewriter()` twice does not create a second reveal or another playback clock.
+
+With external `progress`, `0` hides the animated characters and `1` renders the final state. The existing mapping uses a token span of `1.0`: for a token delay `d` and the largest delay `D`, local progress is `clamp(progress * (D + 1) - d, 0, 1)`. A character is shown when that local value is greater than zero. There is no remembered maximum progress, so moving backward past a character's start hides it again. A spring overshooting `1` alone does not hide an already started character; it must move back across the character's start threshold.
+
+The example's `parentLabelPolicy({ reducedMotion: 'final-state' })` keeps the complete sentence readable when reduced motion is enabled, including an externally controlled instance whose progress is still `0`. The parent exposes the full phrase once to screen readers and the decorative character nodes remain hidden; no per-character announcements are emitted.
+
## Native Renderer Contract
`nativeText()` is the stable MVP renderer. Animated tokens use an animated `View` container with an inner `Text` so transform effects such as `rise()`, `slide()`, `scale()`, and `pulse()` are visible in React Native. Static tokens still render as plain `Text`.
@@ -518,6 +583,8 @@ const ReducedReveal = defineTextMotion()
React Native/Hermes does not guarantee `Intl.Segmenter` in every runtime. The built-in splitters detect it first and use it when available. When it is missing, `graphemes()` and `words()` fall back to a small internal tokenizer so animations do not crash at app startup.
+The grapheme fallback keeps decomposed Hangul syllable sequences such as `한` together, including syllable boundaries involving precomposed Hangul and Jamo, without normalizing or rewriting the source text. It does not implement every Unicode grapheme rule: some Indic conjuncts such as `क्ष` can still split. Per-token rendering also does not guarantee the shaping or ligatures of a single native `Text`.
+
The fallback is designed for resilient UI motion, not full ICU-level locale segmentation. Apps that need exact locale-aware word boundaries, especially for scripts with complex word breaking, should load an `Intl.Segmenter` polyfill such as `@formatjs/intl-segmenter` in the app entrypoint before rendering text motion components, or pass a `custom()` splitter for product-specific tokenization.
## Stable MVP
@@ -527,7 +594,7 @@ The fallback is designed for resilient UI motion, not full ICU-level locale segm
- experimental newline-only `lines()`
- `nativeText()`, `overlayText()`
- `stagger()`, `sequence()`, `parallel()`, `wave()`
-- `fade()`, `rise()`, `slide()`, `scale()`, `pulse()`, `shake()`, `lineReveal()`
+- `fade()`, `rise()`, `slide()`, `scale()`, `pulse()`, `shake()`, `lineReveal()`, `typewriter()`
- `useTextMotionControls()` with explicit `controls` prop for event-driven play/replay/reset/stop
- controlled `progress` via external Reanimated shared values
- parent-label accessibility policy with hidden animated token nodes
@@ -542,7 +609,6 @@ These are intentionally not stable exports in the MVP:
- custom effect factory API
- renderer capability factory API
- `wipe`
-- `typewriter`
- `scramble`
- Skia renderer or Skia-only effects
- playback APIs such as `pause`, `seek`, or `reverse`
@@ -573,7 +639,7 @@ Core text-motion work should stay focused on:
Advanced text effects can be considered when they still depend on text tokens or text layout:
-- typewriter, scramble, wipe, and highlight sweep effects
+- scramble, wipe, and highlight sweep effects
- word-in-line effects and RN-rendered line-to-token mapping when source range policies are stable enough to document
- text-change transitions where old and new token sets need a clear playback policy
@@ -598,6 +664,7 @@ Use it today for:
- hero titles, section titles, short labels, onboarding copy, and product descriptions
- word or grapheme entrance motion with `fade`, `rise`, `slide`, `scale`, `pulse`, or `shake`
+- character-by-character Typewriter reveals that keep the completed phrase's layout
- reusable recipe components created from presets or `defineTextMotion()`
- event-driven replay/reset/stop controls without remounting the component
- raw progress with an external Reanimated shared value
diff --git a/packages/text-motion/scripts/check-declarations.mjs b/packages/text-motion/scripts/check-declarations.mjs
index 91f60ac..f62a14c 100644
--- a/packages/text-motion/scripts/check-declarations.mjs
+++ b/packages/text-motion/scripts/check-declarations.mjs
@@ -3,8 +3,11 @@ import { join } from 'node:path';
const checkedDeclarationFiles = [
'lib/typescript/src/effects/lineReveal.d.ts',
+ 'lib/typescript/src/effects/typewriter.d.ts',
+ 'lib/typescript/src/index.d.ts',
'lib/typescript/src/recipe/recipe.d.ts',
'lib/typescript/src/renderers/index.d.ts',
+ 'lib/typescript/src/renderers/nativeText.d.ts',
'lib/typescript/src/renderers/overlayText.d.ts',
'lib/typescript/src/types/renderer.d.ts',
];
@@ -24,6 +27,7 @@ const forbiddenPatterns = [
/\bTextMotionOverlayStyleTransformStatePair\b/,
/\bcreateOverlayTextStyleTransformStatePair\b/,
/\brevealContent\b/,
+ /\brevealOnStart\b/,
];
const forbiddenPublicOwnershipPatterns = [/\bsplitter\?\s*:\s*never\b/];
const forbiddenRootPatterns = [/\bTextMotionSplitRenderableRecipeBuilder\b/];
diff --git a/packages/text-motion/src/__tests__/capability-types.test.ts b/packages/text-motion/src/__tests__/capability-types.test.ts
index 79ad829..ffb4366 100644
--- a/packages/text-motion/src/__tests__/capability-types.test.ts
+++ b/packages/text-motion/src/__tests__/capability-types.test.ts
@@ -10,6 +10,7 @@ import {
parentLabelPolicy,
scale,
stagger,
+ typewriter,
useTextMotionControls,
wave,
words,
@@ -80,6 +81,22 @@ function expectNativeTextRejectsSkiaOnlyEffects() {
void expectNativeTextRejectsSkiaOnlyEffects;
+function expectTypewriterRequiresSourceTokens() {
+ defineTextMotion().split(graphemes()).layout(nativeText()).effect(typewriter()).component();
+ defineTextMotion().layout(nativeText()).effect(fade().and(typewriter())).component();
+
+ // @ts-expect-error rendered-line geometry cannot reveal individual source tokens.
+ defineTextMotion().layout(overlayText()).effect(typewriter());
+
+ // @ts-expect-error composed effects keep the native-text requirement.
+ defineTextMotion().layout(overlayText()).effect(typewriter().and(fade()));
+
+ // @ts-expect-error Typewriter timing belongs to .timeline(...) and .motion(...).
+ typewriter({ speed: 40 });
+}
+
+void expectTypewriterRequiresSourceTokens;
+
function expectComponentRequiresLayout() {
defineTextMotion().recipe();
diff --git a/packages/text-motion/src/__tests__/effects.test.ts b/packages/text-motion/src/__tests__/effects.test.ts
index 5af1360..c5e76aa 100644
--- a/packages/text-motion/src/__tests__/effects.test.ts
+++ b/packages/text-motion/src/__tests__/effects.test.ts
@@ -7,6 +7,7 @@ import {
scale,
shake,
slide,
+ typewriter,
} from '@react-native-motion-kit/text-motion';
import { readTextMotionEffectDescriptor } from '../recipe/descriptors';
@@ -76,6 +77,20 @@ describe('effects', () => {
);
});
+ it('exposes Typewriter as an opaque native-text effect without timing options', () => {
+ expect(readTextMotionEffectDescriptor(typewriter())).toEqual({
+ kind: 'effect',
+ name: 'typewriter',
+ options: {},
+ requiredCapabilities: ['native-text'],
+ });
+ expect(Object.keys(typewriter())).toEqual(['and']);
+ expect(readTextMotionEffectDescriptor(typewriter().and(fade())).requiredCapabilities).toEqual([
+ 'native-text',
+ 'style-transform',
+ ]);
+ });
+
it('keeps descriptor fields off the public runtime object', () => {
expect(Object.keys(fade())).toEqual(['and']);
});
@@ -88,7 +103,7 @@ describe('effects', () => {
it('does not expose deferred stable APIs', () => {
expect('lineReveal' in textMotion).toBe(true);
- expect('typewriter' in textMotion).toBe(false);
+ expect('typewriter' in textMotion).toBe(true);
expect('scramble' in textMotion).toBe(false);
expect('wipe' in textMotion).toBe(false);
expect('useTextMotionController' in textMotion).toBe(false);
diff --git a/packages/text-motion/src/__tests__/nativeTextPlayback.test.ts b/packages/text-motion/src/__tests__/nativeTextPlayback.test.ts
index 8bd70ce..6a030e9 100644
--- a/packages/text-motion/src/__tests__/nativeTextPlayback.test.ts
+++ b/packages/text-motion/src/__tests__/nativeTextPlayback.test.ts
@@ -168,4 +168,18 @@ describe('nativeTextPlayback', () => {
expect(areNativeTextPlaybackRunsEqual(base, sameReference)).toBe(true);
expect(areNativeTextPlaybackRunsEqual(base, differentReference)).toBe(false);
});
+
+ it('restarts when the reveal gate changes without changing opacity or timing', () => {
+ const base = createPlaybackRun();
+ const typing = createPlaybackRun({ tokenMotion: createTokenMotion({ revealOnStart: true }) });
+
+ expect(areNativeTextPlaybackRunsEqual(base, typing)).toBe(false);
+ expect(areNativeTextPlaybackRunsEqual(typing, base)).toBe(false);
+ expect(
+ areNativeTextPlaybackRunsEqual(
+ typing,
+ createPlaybackRun({ tokenMotion: createTokenMotion({ revealOnStart: true }) }),
+ ),
+ ).toBe(true);
+ });
});
diff --git a/packages/text-motion/src/__tests__/rendererMotion.test.ts b/packages/text-motion/src/__tests__/rendererMotion.test.ts
index f3ce5d9..4d8e563 100644
--- a/packages/text-motion/src/__tests__/rendererMotion.test.ts
+++ b/packages/text-motion/src/__tests__/rendererMotion.test.ts
@@ -7,6 +7,7 @@ import {
shake,
slide,
stagger,
+ typewriter,
} from '@react-native-motion-kit/text-motion';
import { createTextMotionEffect } from '../effects/compose';
@@ -23,6 +24,27 @@ import {
} from '../renderers/rendererMotion';
describe('rendererMotion', () => {
+ it('preserves one start gate across effect ordering, nesting, and duplicate Typewriter effects', () => {
+ const composed = fade({ from: 0.2, to: 0.9 }).and(rise({ y: 6 }));
+ const style = createTextMotionStyleTransformStatePair([composed]);
+
+ [
+ [typewriter().and(composed)],
+ [composed.and(typewriter())],
+ [typewriter(), composed, typewriter()],
+ ].forEach((effects) => {
+ const state = createTextMotionStyleTransformStatePair(effects);
+
+ expect(state).toEqual({ ...style, revealOnStart: true });
+ expect(createTextMotionItemMotion({ effects }, state, 0.04)).toMatchObject({
+ delayMs: 40,
+ initial: style.initial,
+ revealOnStart: true,
+ target: style.target,
+ });
+ });
+ });
+
it('resolves composed built-in effect style states', () => {
expect(
createTextMotionStyleTransformStatePair([
diff --git a/packages/text-motion/src/__tests__/split.test.ts b/packages/text-motion/src/__tests__/split.test.ts
index 116201a..2be944a 100644
--- a/packages/text-motion/src/__tests__/split.test.ts
+++ b/packages/text-motion/src/__tests__/split.test.ts
@@ -65,6 +65,41 @@ describe('splitters', () => {
expect(segments.map((segment) => segment.segment).join('')).toBe('👨👩👧👦 motion');
});
+ it.each([
+ ['decomposed syllables', '\u1112\u1161\u11ab\u1100\u1173\u11af', ['한', '글']],
+ ['extended Jamo', '\ua960\ud7b0\ud7cb', ['\ua960\ud7b0\ud7cb']],
+ ['leading Jamo runs', '\u1100\u1102', ['\u1100\u1102']],
+ ['vowel and trailing runs', '\u1161\u1162\u11a8\u11ab', ['\u1161\u1162\u11a8\u11ab']],
+ ['precomposed LV with Jamo', '가\u1161\u11a8', ['가\u1161\u11a8']],
+ ['precomposed LVT with trailing Jamo', '\u1100각\u11a8', ['\u1100각\u11a8']],
+ ['leading and trailing without a vowel', '\u1100\u11a8', ['\u1100', '\u11a8']],
+ ['a vowel after a closed syllable', '각\u1161', ['각', '\u1161']],
+ ['compatibility Jamo', 'ㄱㅏ', ['ㄱ', 'ㅏ']],
+ ['a mark between Jamo', '\u1100\u0301\u1161', ['\u1100\u0301', '\u1161']],
+ ['a mark after a syllable', '\u1112\u1161\u11ab\u0301', ['\u1112\u1161\u11ab\u0301']],
+ ])('keeps Hangul grapheme boundaries without Intl: %s', (_name, input, expected) => {
+ const segments = fallbackSegments(input, 'grapheme');
+
+ expect(segments.map((segment) => segment.segment)).toEqual(expected);
+ expect(segments.map((segment) => segment.segment).join('')).toBe(input);
+ });
+
+ it('keeps original UTF-16 offsets around decomposed Hangul and emoji without normalizing', () => {
+ const input = 'A한👩🏽💻\r\n글';
+ const segments = fallbackSegments(input, 'grapheme');
+
+ expect(segments).toEqual([
+ { index: 0, segment: 'A' },
+ { index: 1, segment: '한' },
+ { index: 4, segment: '👩🏽💻' },
+ { index: 11, segment: '\r\n' },
+ { index: 13, segment: '글' },
+ ]);
+ segments.forEach(({ index, segment }) => {
+ expect(input.slice(index, index + segment.length)).toBe(segment);
+ });
+ });
+
it('normalizes custom split output into tokens', () => {
const tokens = readTextMotionSplitterDescriptor(
custom((input) => input.match(/\S+/g) ?? []),
diff --git a/packages/text-motion/src/__tests__/typewriter.test.tsx b/packages/text-motion/src/__tests__/typewriter.test.tsx
new file mode 100644
index 0000000..0edfbdd
--- /dev/null
+++ b/packages/text-motion/src/__tests__/typewriter.test.tsx
@@ -0,0 +1,432 @@
+import {
+ defineTextMotion,
+ fade,
+ graphemes,
+ nativeText,
+ overlayText,
+ parentLabelPolicy,
+ rise,
+ stagger,
+ typewriter,
+ type TextMotionMotionConfig,
+} from '@react-native-motion-kit/text-motion';
+import { act, render, screen } from '@testing-library/react-native';
+import * as Reanimated from 'react-native-reanimated';
+
+import {
+ createTextMotionControlsHandle,
+ readTextMotionControlsDescriptor,
+} from '../controls/descriptors';
+import { createTextMotionComponent } from '../recipe/component';
+import {
+ readTextMotionRendererDescriptor,
+ readTextMotionSplitterDescriptor,
+} from '../recipe/descriptors';
+
+function getToken(testID: string) {
+ return screen.getByTestId(testID, { includeHiddenElements: true });
+}
+
+async function updateProgress(progress: Reanimated.SharedValue, value: number) {
+ await act(async () => {
+ progress.value = value;
+ await jest.advanceTimersByTimeAsync(1);
+ });
+}
+
+describe('typewriter', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.mocked(Reanimated.useReducedMotion).mockReturnValue(false);
+ });
+
+ afterEach(() => {
+ jest.mocked(Reanimated.useReducedMotion).mockReset();
+ jest.useRealTimers();
+ });
+
+ it.each([
+ [0, 0],
+ [-1, 0],
+ [Number.NaN, 0],
+ [Number.POSITIVE_INFINITY, 0],
+ [Number.NEGATIVE_INFINITY, 0],
+ [0.000001, 1],
+ [0.5, 1],
+ [1, 1],
+ [2, 1],
+ ])('renders progress %s with discrete opacity %s', async (value, opacity) => {
+ const progress = Reanimated.makeMutable(value);
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 0 } })
+ .component();
+
+ await render(가);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity });
+ expect(Object.is(progress.value, value)).toBe(true);
+ });
+
+ it('keeps the existing timeline mapping and reverses without owning external progress', async () => {
+ const progress = Reanimated.makeMutable(0.31);
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .timeline(stagger(0.05))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 0 } })
+ .component();
+ await render(0123456789);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-8')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-9')).toHaveAnimatedStyle({ opacity: 0 });
+
+ await updateProgress(progress, 0.311);
+
+ expect(getToken('glyph-9')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(progress.value).toBe(0.311);
+
+ await updateProgress(progress, 0.31);
+
+ expect(getToken('glyph-9')).toHaveAnimatedStyle({ opacity: 0 });
+
+ await updateProgress(progress, 0);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 0 });
+ expect(getToken('glyph-9')).toHaveAnimatedStyle({ opacity: 0 });
+ expect(progress.value).toBe(0);
+ });
+
+ it('preserves full text and layout nodes while whitespace and newlines consume no motion slots', async () => {
+ const progress = Reanimated.makeMutable(0.05);
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .timeline(stagger(0.04))
+ .effect(typewriter())
+ .component();
+ await render(
+
+ {' A \r\nB! '}
+ ,
+ );
+ const initialChildren = screen.getByTestId('sentence').children.length;
+
+ expect(getToken('glyph-0')).toHaveProp('children', ' ');
+ expect(getToken('glyph-1')).toHaveTextContent('A');
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-2')).toHaveProp('children', ' ');
+ expect(getToken('glyph-line-break-3')).toHaveProp('accessible', false);
+ expect(getToken('glyph-4')).toHaveTextContent('B');
+ expect(getToken('glyph-4')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-5')).toHaveTextContent('!');
+ expect(getToken('glyph-5')).toHaveAnimatedStyle({ opacity: 0 });
+ expect(getToken('glyph-6')).toHaveProp('children', ' ');
+
+ await updateProgress(progress, 1);
+
+ expect(screen.getByTestId('sentence').children).toHaveLength(initialChildren);
+ expect(getToken('glyph-5')).toHaveAnimatedStyle({ opacity: 1 });
+ });
+
+ it('multiplies the existing fade opacity without changing effect order or rise progress', async () => {
+ const progress = Reanimated.makeMutable(0);
+ const TypewriterFirst = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'first' }))
+ .effect(
+ typewriter()
+ .and(fade({ from: 0.2, to: 0.8 }))
+ .and(rise({ y: 8 })),
+ )
+ .component();
+ const TypewriterLast = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'last' }))
+ .effect(
+ fade({ from: 0.2, to: 0.8 })
+ .and(rise({ y: 8 }))
+ .and(typewriter()),
+ )
+ .component();
+ await render(
+ <>
+ A
+ A
+ >,
+ );
+
+ expect(getToken('first-0')).toHaveAnimatedStyle({ opacity: 0 });
+ expect(getToken('last-0')).toHaveAnimatedStyle({ opacity: 0 });
+
+ await updateProgress(progress, 0.5);
+
+ const halfwayStyle = {
+ opacity: 0.5,
+ transform: [{ translateX: 0 }, { translateY: 4 }, { scale: 1 }],
+ };
+ expect(getToken('first-0')).toHaveAnimatedStyle(halfwayStyle);
+ expect(getToken('last-0')).toHaveAnimatedStyle(halfwayStyle);
+ });
+
+ it('replays, resets and stops visible tokens through the existing controls channel', async () => {
+ const controls = createTextMotionControlsHandle();
+ const descriptor = readTextMotionControlsDescriptor(controls);
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .timeline(stagger(0.2))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 100 } })
+ .component();
+ const view = await render(AB);
+
+ expect(descriptor.getListenerCount()).toBe(2);
+ jest.advanceTimersByTime(50);
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 0 });
+
+ await act(async () => {
+ controls.stop();
+ await jest.advanceTimersByTimeAsync(1);
+ });
+ jest.advanceTimersByTime(500);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 0 });
+
+ await act(async () => {
+ controls.reset();
+ await jest.advanceTimersByTimeAsync(1);
+ });
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 0 });
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 0 });
+
+ await act(async () => {
+ controls.play();
+ });
+ jest.advanceTimersByTime(350);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 1 });
+
+ await act(async () => {
+ controls.replay();
+ });
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 0 });
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 0 });
+
+ await view.rerender(AB);
+ expect(descriptor.getListenerCount()).toBe(2);
+ await view.unmount();
+ expect(descriptor.getListenerCount()).toBe(0);
+ });
+
+ it('keeps the owned timeline running across the same-input parent rerender', async () => {
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .timeline(stagger(0.2))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 100 } })
+ .component();
+ const view = await render(AB);
+
+ jest.advanceTimersByTime(150);
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 0 });
+ await view.rerender(AB);
+ jest.advanceTimersByTime(100);
+
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 1 });
+ });
+
+ it('keeps existing token playback when text is appended and delays only the new token', async () => {
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .timeline(stagger(0.2))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 100 } })
+ .component();
+ const view = await render(AB);
+
+ jest.advanceTimersByTime(150);
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 0 });
+
+ await view.rerender(ABC);
+ jest.advanceTimersByTime(100);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-2')).toHaveTextContent('C');
+ expect(getToken('glyph-2')).toHaveAnimatedStyle({ opacity: 0 });
+
+ jest.advanceTimersByTime(350);
+
+ expect(getToken('glyph-2')).toHaveAnimatedStyle({ opacity: 1 });
+ });
+
+ it('restarts the changed token when text is replaced without changing its shape', async () => {
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .effect(typewriter().and(rise({ y: 8 })))
+ .motion({ kind: 'timing', options: { duration: 400 } })
+ .component();
+ const view = await render(A);
+
+ jest.advanceTimersByTime(200);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({
+ opacity: 1,
+ transform: [{ translateX: 0 }, { translateY: 4 }, { scale: 1 }],
+ });
+
+ await view.rerender(B);
+
+ expect(getToken('glyph-0')).toHaveTextContent('B');
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({
+ transform: [{ translateX: 0 }, { translateY: 8 }, { scale: 1 }],
+ });
+
+ jest.advanceTimersByTime(200);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({
+ opacity: 1,
+ transform: [{ translateX: 0 }, { translateY: 4 }, { scale: 1 }],
+ });
+ });
+
+ it('restarts the same text when the app changes its React key', async () => {
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .effect(typewriter().and(rise({ y: 8 })))
+ .motion({ kind: 'timing', options: { duration: 400 } })
+ .component();
+ const view = await render(A);
+
+ jest.advanceTimersByTime(200);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({
+ opacity: 1,
+ transform: [{ translateX: 0 }, { translateY: 4 }, { scale: 1 }],
+ });
+
+ await view.rerender(A);
+
+ expect(getToken('glyph-0')).toHaveTextContent('A');
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({
+ transform: [{ translateX: 0 }, { translateY: 8 }, { scale: 1 }],
+ });
+
+ jest.advanceTimersByTime(200);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({
+ opacity: 1,
+ transform: [{ translateX: 0 }, { translateY: 4 }, { scale: 1 }],
+ });
+ });
+
+ it('reveals owned zero-duration tokens after their existing stagger delays', async () => {
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .timeline(stagger(0.04))
+ .effect(typewriter())
+ .motion({ kind: 'timing', options: { duration: 0 } })
+ .component();
+
+ await render(AB);
+ jest.advanceTimersByTime(20);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 0 });
+
+ jest.advanceTimersByTime(60);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken('glyph-1')).toHaveAnimatedStyle({ opacity: 1 });
+ });
+
+ it('restarts playback when the gate changes even if the style endpoints stay identical', async () => {
+ const Renderer = readTextMotionRendererDescriptor(
+ nativeText({ testIDPrefix: 'glyph' }),
+ ).Component;
+ const tokens = readTextMotionSplitterDescriptor(graphemes()).split('A');
+ const motion: TextMotionMotionConfig = { kind: 'timing', options: { duration: 400 } };
+ const view = await render(
+
+ A
+ ,
+ );
+
+ jest.advanceTimersByTime(200);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({
+ opacity: 1,
+ transform: [{ translateX: 0 }, { translateY: 4 }, { scale: 1 }],
+ });
+
+ await view.rerender(
+
+ A
+ ,
+ );
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({
+ transform: [{ translateX: 0 }, { translateY: 8 }, { scale: 1 }],
+ });
+
+ jest.advanceTimersByTime(200);
+
+ expect(getToken('glyph-0')).toHaveAnimatedStyle({
+ opacity: 1,
+ transform: [{ translateX: 0 }, { translateY: 4 }, { scale: 1 }],
+ });
+ });
+
+ it('uses the complete accessible label and final-state policy with external progress at zero', async () => {
+ jest.mocked(Reanimated.useReducedMotion).mockReturnValue(true);
+ const progress = Reanimated.makeMutable(0);
+ const Reveal = defineTextMotion()
+ .split(graphemes())
+ .layout(nativeText({ testIDPrefix: 'glyph' }))
+ .timeline(stagger(0.04))
+ .effect(typewriter())
+ .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' }))
+ .component();
+
+ await render(
+
+ 안녕!
+ ,
+ );
+
+ expect(screen.getByTestId('sentence')).toHaveAccessibleName('안녕!');
+ ['glyph-0', 'glyph-1', 'glyph-2'].forEach((testID) => {
+ expect(getToken(testID)).toHaveAnimatedStyle({ opacity: 1 });
+ expect(getToken(testID)).toHaveProp('accessible', false);
+ expect(getToken(testID)).toHaveProp('accessibilityElementsHidden', true);
+ });
+ expect(progress.value).toBe(0);
+ });
+
+ it('rejects a typewriter effect on overlayText through an unsafe runtime recipe', async () => {
+ const UnsafeReveal = createTextMotionComponent({
+ effects: [typewriter()],
+ renderer: overlayText(),
+ });
+
+ await expect(render(Unsupported line typing)).rejects.toThrow(
+ 'effect "typewriter" requires capability "native-text", but renderer "overlayText" does not provide it',
+ );
+ });
+});
diff --git a/packages/text-motion/src/effects/index.ts b/packages/text-motion/src/effects/index.ts
index cacbae3..748a348 100644
--- a/packages/text-motion/src/effects/index.ts
+++ b/packages/text-motion/src/effects/index.ts
@@ -6,3 +6,4 @@ export { rise, type RiseOptions } from './rise';
export { scale, type ScaleOptions } from './scale';
export { shake, type ShakeOptions } from './shake';
export { slide, type SlideOptions } from './slide';
+export { typewriter } from './typewriter';
diff --git a/packages/text-motion/src/effects/typewriter.ts b/packages/text-motion/src/effects/typewriter.ts
new file mode 100644
index 0000000..c8dd865
--- /dev/null
+++ b/packages/text-motion/src/effects/typewriter.ts
@@ -0,0 +1,16 @@
+import { createTextMotionEffect } from './compose';
+
+/**
+ * Reveal each native-text token when its local progress becomes positive.
+ *
+ * Keeps the final token layout in place. Combine with `graphemes()`, `stagger(seconds)`,
+ * and `.motion({ kind: 'timing', options: { duration: 0 } })` for instant character reveals.
+ * The splitter and timeline determine the reveal units and order; whitespace does not
+ * consume a motion index. Other effects retain their own opacity and transforms.
+ *
+ * External `progress` follows the recipe timeline, not the fraction of visible characters.
+ * Decreasing progress can hide tokens again. Requires `nativeText()`.
+ */
+export function typewriter() {
+ return createTextMotionEffect('typewriter');
+}
diff --git a/packages/text-motion/src/index.tsx b/packages/text-motion/src/index.tsx
index fcf49bf..d5f1348 100644
--- a/packages/text-motion/src/index.tsx
+++ b/packages/text-motion/src/index.tsx
@@ -1,6 +1,6 @@
export { parentLabelPolicy } from './accessibility';
export { useTextMotionControls } from './controls';
-export { fade, lineReveal, pulse, rise, scale, shake, slide } from './effects';
+export { fade, lineReveal, pulse, rise, scale, shake, slide, typewriter } from './effects';
export { defineTextMotion } from './recipe';
export { nativeText, overlayText } from './renderers';
export { custom, graphemes, lines, words } from './split';
diff --git a/packages/text-motion/src/renderers/nativeText.tsx b/packages/text-motion/src/renderers/nativeText.tsx
index c3c96dd..d5abb27 100644
--- a/packages/text-motion/src/renderers/nativeText.tsx
+++ b/packages/text-motion/src/renderers/nativeText.tsx
@@ -435,6 +435,7 @@ function useNativeTextAnimatedStyle(options: NativeTextAnimatedStyleOptions) {
const targetTranslateX = tokenMotion.target.translateX;
const targetTranslateY = tokenMotion.target.translateY;
const pulseScale = tokenMotion.pulseScale;
+ const revealOnStart = tokenMotion.revealOnStart === true;
const itemDelaySeconds = controlled ? options.controlledProgressPlan.itemDelaySeconds : 0;
const totalTimelineSpan = controlled ? options.controlledProgressPlan.totalTimelineSpan : 1;
@@ -455,7 +456,10 @@ function useNativeTextAnimatedStyle(options: NativeTextAnimatedStyleOptions) {
const pulseMultiplier = 1 + (pulseScale - 1) * pulseProgress;
return {
- opacity: initialOpacity + (targetOpacity - initialOpacity) * current,
+ opacity:
+ revealOnStart && current <= 0
+ ? 0
+ : initialOpacity + (targetOpacity - initialOpacity) * current,
transform: [
{
translateX: initialTranslateX + (targetTranslateX - initialTranslateX) * current,
@@ -477,6 +481,7 @@ function useNativeTextAnimatedStyle(options: NativeTextAnimatedStyleOptions) {
progress,
pulseScale,
renderFinalState,
+ revealOnStart,
targetOpacity,
targetScale,
targetTranslateX,
diff --git a/packages/text-motion/src/renderers/nativeTextPlayback.ts b/packages/text-motion/src/renderers/nativeTextPlayback.ts
index b87266f..0cc392f 100644
--- a/packages/text-motion/src/renderers/nativeTextPlayback.ts
+++ b/packages/text-motion/src/renderers/nativeTextPlayback.ts
@@ -194,6 +194,7 @@ export function areNativeTextPlaybackRunsEqual(
Object.is(left.delayMs, right.delayMs) &&
Object.is(left.pulseScale, right.pulseScale) &&
left.reducedMotion === right.reducedMotion &&
+ left.revealOnStart === right.revealOnStart &&
left.renderFinalState === right.renderFinalState &&
left.text === right.text &&
areNativeTextStyleStatesEqual(left.initial, right.initial) &&
diff --git a/packages/text-motion/src/renderers/rendererMotion.ts b/packages/text-motion/src/renderers/rendererMotion.ts
index d65d8a7..f758074 100644
--- a/packages/text-motion/src/renderers/rendererMotion.ts
+++ b/packages/text-motion/src/renderers/rendererMotion.ts
@@ -31,6 +31,7 @@ export type TextMotionStyleTransformState = {
export type TextMotionStyleTransformStatePair = {
initial: TextMotionStyleTransformState;
pulseScale: number;
+ revealOnStart?: true;
target: TextMotionStyleTransformState;
};
@@ -46,6 +47,7 @@ export type TextMotionItemMotion = {
motion?: TextMotionMotionConfig;
pulseScale: number;
reducedMotion: 'final-state' | 'system';
+ revealOnStart?: true;
};
export type TextMotionControlledProgressPlan = {
@@ -65,6 +67,7 @@ type TextMotionStyleStateTransform = (
type EffectStyleStateChange = {
initial?: TextMotionStyleStateTransform;
pulseScale?: TextMotionNumberTransform;
+ revealOnStart?: true;
target?: TextMotionStyleStateTransform;
};
@@ -87,6 +90,9 @@ const preserveStyleState: TextMotionStyleStateTransform = (state) => state;
const preserveNumber: TextMotionNumberTransform = (value) => value;
const effectStyleStateResolvers: Record = {
+ typewriter() {
+ return { revealOnStart: true };
+ },
fade(effect) {
return {
initial: setStyleNumber('opacity', numberOption(effect.options, 'from', 0)),
@@ -289,6 +295,8 @@ function applyResolvedEffectStyleStateChange(
const target = effectStyleStateChange.target ?? preserveStyleState;
return {
+ ...styleState,
+ ...(effectStyleStateChange.revealOnStart ? { revealOnStart: true } : {}),
initial: initial(styleState.initial),
pulseScale: pulseScale(styleState.pulseScale),
target: target(styleState.target),
@@ -359,12 +367,10 @@ export function createTextMotionItemMotion(
delaySeconds: number,
): TextMotionItemMotion {
return {
+ ...styleState,
delayMs: createDelayMs(delaySeconds),
- initial: styleState.initial,
motion: recipe.motion,
- pulseScale: styleState.pulseScale,
reducedMotion: recipe.accessibility?.reducedMotion ?? 'system',
- target: styleState.target,
};
}
diff --git a/packages/text-motion/src/split/segmenter.ts b/packages/text-motion/src/split/segmenter.ts
index c518f8e..d64d969 100644
--- a/packages/text-motion/src/split/segmenter.ts
+++ b/packages/text-motion/src/split/segmenter.ts
@@ -32,7 +32,17 @@ const SURROGATE_PAIR_SOURCE = '[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]';
const CODE_POINT_SOURCE = `(?:\\r\\n|${SURROGATE_PAIR_SOURCE}|[\\s\\S])`;
const GRAPHEME_EXTENSION_SOURCE =
'[\\u0300-\\u036F\\u1AB0-\\u1AFF\\u1DC0-\\u1DFF\\u20D0-\\u20FF\\uFE20-\\uFE2F\\uFE00-\\uFE0F\\u{E0100}-\\u{E01EF}\\u{1F3FB}-\\u{1F3FF}]';
-const BASE_GRAPHEME_SOURCE = `${CODE_POINT_SOURCE}${GRAPHEME_EXTENSION_SOURCE}*`;
+const HANGUL_LEADING_SOURCE = '[\\u1100-\\u115F\\uA960-\\uA97C]';
+const HANGUL_VOWEL_SOURCE = '[\\u1160-\\u11A7\\uD7B0-\\uD7C6]';
+const HANGUL_TRAILING_SOURCE = '[\\u11A8-\\u11FF\\uD7CB-\\uD7FB]';
+// Precomposed LV syllables occur every 28 code points, starting at 가 (U+AC00).
+const HANGUL_LV_SOURCE = `[${Array.from({ length: 399 }, (_, index) =>
+ String.fromCharCode(0xac00 + index * 28),
+).join('')}]`;
+const HANGUL_LVT_SOURCE = `(?!${HANGUL_LV_SOURCE})[\\uAC00-\\uD7A3]`;
+// UAX #29 GB6–GB8: L* (V+ | LV V* | LVT) T* | L+ | T+.
+const HANGUL_SYLLABLE_SOURCE = `${HANGUL_LEADING_SOURCE}*(?:${HANGUL_VOWEL_SOURCE}+|${HANGUL_LV_SOURCE}${HANGUL_VOWEL_SOURCE}*|${HANGUL_LVT_SOURCE})${HANGUL_TRAILING_SOURCE}*|${HANGUL_LEADING_SOURCE}+|${HANGUL_TRAILING_SOURCE}+`;
+const BASE_GRAPHEME_SOURCE = `(?:${HANGUL_SYLLABLE_SOURCE}|${CODE_POINT_SOURCE})${GRAPHEME_EXTENSION_SOURCE}*`;
const JOINED_GRAPHEME_SOURCE = `${BASE_GRAPHEME_SOURCE}(?:\\u200D${BASE_GRAPHEME_SOURCE})*`;
const REGIONAL_INDICATOR_PAIR_SOURCE = '[\\u{1F1E6}-\\u{1F1FF}]{2}';
const GRAPHEME_SEGMENT_SOURCE = `${REGIONAL_INDICATOR_PAIR_SOURCE}|${JOINED_GRAPHEME_SOURCE}`;