react-native-ai-animations 0.8.0 → 0.9.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/README.md CHANGED
@@ -1,72 +1,71 @@
1
1
  # React Native AI Animations
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/react-native-ai-animations)](https://www.npmjs.com/package/react-native-ai-animations)
4
+ [![license](https://img.shields.io/npm/l/react-native-ai-animations)](LICENSE)
5
+
3
6
  Animated React Native and Expo components for AI chat apps: streaming text,
4
7
  shimmer loading text, thinking and status indicators, voice orbs and
5
8
  waveforms, image and video generation loaders, question cards and reasoning
6
9
  effort bars. Built on Reanimated and Skia, themeable, and reduce motion aware.
7
10
 
11
+ **Docs and live demos:** [nami-motion-web.vercel.app](https://nami-motion-web.vercel.app/)
12
+
13
+ ## Installation
14
+
8
15
  ```bash
9
16
  npx expo install react-native-ai-animations react-native-reanimated react-native-worklets
10
17
  ```
11
18
 
12
- Formerly published as `nami-motion`: replace `nami-motion` with
13
- `react-native-ai-animations` in your imports.
19
+ The orbs, waveforms and media loaders in `react-native-ai-animations/skia` also need Skia:
20
+
21
+ ```bash
22
+ npx expo install @shopify/react-native-skia
23
+ ```
24
+
25
+ | Peer | Version |
26
+ | --- | --- |
27
+ | `react` | 19.2 or newer |
28
+ | `react-native` | 0.83 or newer |
29
+ | `react-native-reanimated` | 4.2 or newer |
30
+ | `react-native-worklets` | 0.7 or newer |
31
+ | `@shopify/react-native-skia` | 2.4 or newer, optional |
32
+
33
+ > Formerly published as `nami-motion`. Replace `nami-motion` with
34
+ > `react-native-ai-animations` in your imports.
14
35
 
15
36
  ## Components
16
37
 
17
- <table>
18
- <tr>
19
- <td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/spinning-mark.gif" width="200" alt="Spinning Mark recording" /><br /><b>Spinning Mark</b><br /><code>react-native-ai-animations/brand</code></td>
20
- <td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/shimmer-text.gif" width="200" alt="Shimmer Text recording" /><br /><b>Shimmer Text</b><br /><code>react-native-ai-animations/text</code></td>
21
- <td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/streaming-text.gif" width="200" alt="Streaming Text recording" /><br /><b>Streaming Text</b><br /><code>react-native-ai-animations/text</code></td>
22
- </tr>
23
- <tr>
24
- <td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/status-row.gif" width="200" alt="Status Row recording" /><br /><b>Status Row</b><br /><code>react-native-ai-animations/chat</code></td>
25
- <td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/thinking-block.gif" width="200" alt="Thinking Block recording" /><br /><b>Thinking Block</b><br /><code>react-native-ai-animations/chat</code></td>
26
- <td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/question-card.gif" width="200" alt="Question Card recording" /><br /><b>Question Card</b><br /><code>react-native-ai-animations/chat</code></td>
27
- </tr>
28
- <tr>
29
- <td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/effort-bar.gif" width="200" alt="Effort Bar recording" /><br /><b>Effort Bar</b><br /><code>react-native-ai-animations/chat</code></td>
30
- <td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/media-generating.gif" width="200" alt="Media Generating recording" /><br /><b>Media Generating</b><br /><code>react-native-ai-animations/skia</code></td>
31
- <td align="center" valign="top" width="33%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/voice-wave.gif" width="200" alt="Voice Wave recording" /><br /><b>Voice Wave</b><br /><code>react-native-ai-animations/skia</code></td>
32
- </tr>
33
- </table>
34
-
35
- <table>
36
- <tr>
37
- <td align="center" valign="top" width="50%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/shimmer-text.gif" width="160" alt="Shimmer Text recording" /><br /><b>Shimmer Text</b><br /><code>react-native-ai-animations/text</code><br /><a href="https://github.com/AhmeetKaar/nami-motion/raw/main/media/shimmer-text.mp4">10s video</a></td>
38
- <td align="center" valign="top" width="50%"><img src="https://raw.githubusercontent.com/AhmeetKaar/nami-motion/main/media/voice-wave.gif" width="160" alt="Voice Wave recording" /><br /><b>Voice Wave</b><br /><code>react-native-ai-animations/skia</code><br /><a href="https://github.com/AhmeetKaar/nami-motion/raw/main/media/voice-wave.mp4">10s video</a></td>
39
- </tr>
40
- </table>
38
+ Every component and style is shown in motion, with copyable code and agent
39
+ prompts, on the [website](https://nami-motion-web.vercel.app/).
41
40
 
42
41
  | Component | Entry | What it does |
43
42
  | --- | --- | --- |
44
- | Shimmer Text | `react-native-ai-animations/text` | A light band sweeping across text while a response is being generated. |
45
- | Streaming Text | `react-native-ai-animations/text` | Words of a streaming answer fade and rise in, with a blinking caret. |
46
- | Spoken Text | `react-native-ai-animations/text` | Highlights the word being spoken and dims the words already read aloud, for voice replies. |
47
- | Spinning Mark | `react-native-ai-animations/brand` | Spins any icon you pass (your logo) with optional breathing and entrance. |
48
- | Media Generating | `react-native-ai-animations/skia` | One loading state shared by image and video generation, with six variants (blobs, matrix, orbit, scan, constellation, helix). `ImageGenerating` and `VideoGenerating` are its presets. |
49
- | DotOrb | `react-native-ai-animations/skia` | Dot sphere pictures behind the media variants, also usable on their own. |
50
- | Liquid Orb | `react-native-ai-animations/skia` | Liquid voice orb in metal, water, iridescent or ring material that reacts to idle, listening, thinking and speaking states and a live level. |
51
- | Voice Wave | `react-native-ai-animations/skia` | Live microphone waveform in four styles: lines, bars, dots and pulse. |
52
- | Status Row | `react-native-ai-animations/chat` | Pulsing icon, shimmering label and elapsed timer for agent status. |
53
- | Thinking Block | `react-native-ai-animations/chat` | "Thought for Ns" header that springs open into a step timeline. |
54
- | Question Card | `react-native-ai-animations/chat` | Paged questions with numbered options and a custom answer, for cards or sheets. |
55
- | Effort Bar | `react-native-ai-animations/chat` | Reasoning effort bar wired to `reasoning.effort` (low, medium, high) in four styles: rising bars (default), track, segmented pill and a slider whose knob stretches as you drag and snaps to a level under a tab with your model name, each with a compact form and a per level icon. |
43
+ | Shimmer Text | `/text` | A light band sweeping across text while a response is being generated. |
44
+ | Streaming Text | `/text` | A streaming answer fades in chunk by chunk with native text layout and a blinking caret. |
45
+ | Spoken Text | `/text` | Highlights the word being spoken and dims the words already read aloud, for voice replies. |
46
+ | Spinning Mark | `/brand` | Spins any icon you pass (your logo) with optional breathing and entrance. |
47
+ | Status Row | `/chat` | Pulsing icon, shimmering label and elapsed timer for agent status. |
48
+ | Thinking Block | `/chat` | "Thought for Ns" header that springs open into a step timeline. |
49
+ | Question Card | `/chat` | Paged questions with numbered options and a custom answer, as a card or a sheet. |
50
+ | Effort Bar | `/chat` | Reasoning effort control wired to `reasoning.effort` (low, medium, high) in four styles: bars, track, segmented and slider. |
51
+ | Media Generating | `/skia` | One loading state for image and video generation in six variants: blobs, matrix, orbit, scan, constellation and helix. |
52
+ | Dot Orb | `/skia` | The dot sphere behind the media variants, also usable on its own. |
53
+ | Matrix Orb | `/skia` | Dot matrix orb with idle, listening and thinking states and per state colors. |
54
+ | Liquid Orb | `/skia` | Voice orb in metal, water, iridescent or ring material that reacts to idle, listening, thinking and speaking and a live level. |
55
+ | Voice Wave | `/skia` | Live microphone waveform in four styles: lines, bars, dots and pulse. |
56
+
57
+ Entries are subpaths of `react-native-ai-animations`. The root export holds
58
+ `/theme` and `/text`; nothing that needs Skia is imported from the root.
56
59
 
57
60
  Every style is also its own component with the same props minus `variant`:
58
- `VoiceWaveLines`, `VoiceWaveBars`, `VoiceWaveDots` and `VoiceWavePulse`;
59
- `EffortBars`, `EffortTrack`, `EffortSegmented` and
60
- `EffortSlider`; `MediaGeneratingBlobs`, `MediaGeneratingMatrix`,
61
- `MediaGeneratingOrbit`, `MediaGeneratingScan`, `MediaGeneratingConstellation`
62
- and `MediaGeneratingHelix`; and `QuestionSheet`.
63
61
 
64
- Every component takes its icon, font, colors and timing as props and falls back
65
- to a static state when reduce motion is on. `react-native-ai-animations/theme` ships the
66
- Moodstone palette with `MotionThemeProvider` and `useMotionPalette`; pass
67
- `colors` to override one component.
62
+ - `VoiceWaveLines`, `VoiceWaveBars`, `VoiceWaveDots`, `VoiceWavePulse`
63
+ - `EffortBars`, `EffortTrack`, `EffortSegmented`, `EffortSlider`
64
+ - `MediaGeneratingBlobs`, `MediaGeneratingMatrix`, `MediaGeneratingOrbit`,
65
+ `MediaGeneratingScan`, `MediaGeneratingConstellation`, `MediaGeneratingHelix`
66
+ - `QuestionSheet`
68
67
 
69
- Optional peer: `@shopify/react-native-skia` for `react-native-ai-animations/skia`.
68
+ ## Usage
70
69
 
71
70
  ### Shimmer Text
72
71
 
@@ -76,16 +75,6 @@ import { ShimmerText } from "react-native-ai-animations/text";
76
75
  <ShimmerText text="Generating a response" activeColor={colors.text} mutedColor={colors.textTertiary} />;
77
76
  ```
78
77
 
79
- ### Spinning Mark
80
-
81
- ```tsx
82
- import { SpinningMark } from "react-native-ai-animations/brand";
83
-
84
- <SpinningMark size={160} duration={3600} breathe entrance>
85
- <Image source={require("./logo.png")} style={{ width: 160, height: 160 }} />
86
- </SpinningMark>;
87
- ```
88
-
89
78
  ### Streaming Text, Status Row, Thinking Block
90
79
 
91
80
  ```tsx
@@ -97,20 +86,33 @@ import { StatusRow, ThinkingBlock } from "react-native-ai-animations/chat";
97
86
  <StreamingText text={answer} streaming={streaming} textStyle={{ fontFamily: "Inter" }} />;
98
87
  ```
99
88
 
100
- ### Media Generating (shared by image and video)
89
+ ### Spinning Mark
101
90
 
102
91
  ```tsx
103
- import { ImageGenerating, MediaGenerating, VideoGenerating } from "react-native-ai-animations/skia";
92
+ import { SpinningMark } from "react-native-ai-animations/brand";
104
93
 
105
- <MediaGenerating variant="constellation" label="Creating image" startedAt={startedAt} />;
106
- <MediaGenerating variant="scan" chrome="overlay" status="generating" progress={0.42} meta="720p · 6s" aspectRatio={16 / 9} />;
107
- <ImageGenerating variant="helix" startedAt={startedAt} />;
108
- <VideoGenerating status="queued" variant="orbit" />;
94
+ <SpinningMark size={160} duration={3600} breathe entrance>
95
+ <Image source={require("./logo.png")} style={{ width: 160, height: 160 }} />
96
+ </SpinningMark>;
109
97
  ```
110
98
 
99
+ ### Question Card
100
+
101
+ ```tsx
102
+ import { QuestionCard } from "react-native-ai-animations/chat";
103
+
104
+ <QuestionCard questions={questions} variant="sheet" onSubmit={(answers) => send(answers)} />;
105
+ ```
106
+
107
+ `QuestionCard` and `QuestionSheet` take a `ref` with `select(index)`,
108
+ `goTo(page)` and `submit()`, so a demo, a voice flow or a test can drive the
109
+ questions without touches.
110
+
111
111
  ### Effort Bar
112
112
 
113
- `REASONING_EFFORTS` matches the `reasoning.effort` values OpenRouter and most reasoning APIs take (`low`, `medium`, `high`), with each level's share of max_tokens. `variant` picks the style: `bars` (default), `track`, `segmented` or `slider`; `size="compact"` gives each one a small inline form for model rows, `fillColors` tints any of them, `tabLabel` puts your model name in the slider's tab and `levelIcon` swaps every level name for your own icon, so the state reads without text. EffortBar is only the bar: put your own title, level label or description around it, wherever your UX needs it.
113
+ `REASONING_EFFORTS` matches the `reasoning.effort` values OpenRouter and most
114
+ reasoning APIs take (`low`, `medium`, `high`), with each level's share of
115
+ `max_tokens`. `reasoningRequest(effort)` builds the request fields.
114
116
 
115
117
  ```tsx
116
118
  import { EffortBar, REASONING_EFFORTS, reasoningRequest } from "react-native-ai-animations/chat";
@@ -123,15 +125,29 @@ import { EffortBar, REASONING_EFFORTS, reasoningRequest } from "react-native-ai-
123
125
  const body = { model, messages, max_tokens: 16000, ...reasoningRequest(effort) };
124
126
  ```
125
127
 
126
- ### Question Card
128
+ | Prop | Does |
129
+ | --- | --- |
130
+ | `variant` | `bars` (default), `track`, `segmented` or `slider` |
131
+ | `size="compact"` | Small inline form for model rows |
132
+ | `fillColors` | Tints the fill of any style |
133
+ | `tabLabel` | Your model name in the slider's tab |
134
+ | `levelIcon` | Replaces every level name with your own icon |
135
+
136
+ EffortBar is only the control: put your own title, level label or description
137
+ around it.
138
+
139
+ ### Media Generating
127
140
 
128
141
  ```tsx
129
- import { QuestionCard } from "react-native-ai-animations/chat";
142
+ import { ImageGenerating, MediaGenerating, VideoGenerating } from "react-native-ai-animations/skia";
130
143
 
131
- <QuestionCard questions={questions} variant="sheet" onSubmit={(answers) => send(answers)} />;
144
+ <MediaGenerating variant="constellation" label="Creating image" startedAt={startedAt} />;
145
+ <MediaGenerating variant="scan" chrome="overlay" status="generating" progress={0.42} meta="720p · 6s" aspectRatio={16 / 9} />;
146
+ <ImageGenerating variant="helix" startedAt={startedAt} />;
147
+ <VideoGenerating status="queued" variant="orbit" />;
132
148
  ```
133
149
 
134
- Both components take a `ref` with `select(index)`, `goTo(page)` and `submit()`, so a demo, a voice flow or a test can drive the questions without touches.
150
+ `ImageGenerating` and `VideoGenerating` are presets of `MediaGenerating`.
135
151
 
136
152
  ### Voice Wave
137
153
 
@@ -144,9 +160,13 @@ const level = useSharedValue(0);
144
160
  <VoiceWave level={level} variant="bars" colors={["#1F8A70", "#88C9F7", "#5FC7B0"]} />;
145
161
  ```
146
162
 
147
- ### Liquid Orb and Spoken Text (voice mode)
163
+ ### Liquid Orb and Spoken Text
148
164
 
149
- `LiquidOrb` is a Skia liquid orb in `metal`, `water` or `iridescent`, or three glowing loops with `ring` (color them with `colors`); `state` switches between `idle`, `listening`, `thinking` and `speaking`, and `level` (0-1) makes it swell while listening or speaking. `SpokenText` highlights the word being read aloud: pass the index of the current word as `spoken`.
165
+ `LiquidOrb` comes in `metal`, `water` or `iridescent`, or three glowing loops
166
+ with `ring` (color them with `colors`). `state` switches between `idle`,
167
+ `listening`, `thinking` and `speaking`, and `level` (0 to 1) makes it swell
168
+ while listening or speaking. `SpokenText` highlights the word being read aloud:
169
+ pass the index of the current word as `spoken`.
150
170
 
151
171
  ```tsx
152
172
  import { useSharedValue } from "react-native-reanimated";
@@ -160,6 +180,38 @@ const level = useSharedValue(0);
160
180
  <SpokenText text={reply} spoken={wordIndex} textStyle={{ fontFamily: "Inter", fontSize: 20, lineHeight: 28 }} />;
161
181
  ```
162
182
 
183
+ ## Theming
184
+
185
+ Components use the built-in Moodstone palette until you wrap your app in
186
+ `MotionThemeProvider`. Pass one palette, or a `light` and `dark` pair with a
187
+ `colorScheme`. Components that read the palette also take `colors` to override
188
+ a single instance; Shimmer Text, Voice Wave, Dot Orb and Matrix Orb take their
189
+ colors directly (`activeColor`, `colors`, `color`).
190
+
191
+ ```tsx
192
+ import { useColorScheme } from "react-native";
193
+ import { MotionThemeProvider } from "react-native-ai-animations/theme";
194
+
195
+ <MotionThemeProvider
196
+ colorScheme={useColorScheme() ?? "light"}
197
+ palette={{ light: { accent: "#2F7BF5" }, dark: { accent: "#5AA2FF" } }}
198
+ >
199
+ <App />
200
+ </MotionThemeProvider>;
201
+ ```
202
+
203
+ Palette keys: `background`, `card`, `surface`, `raised`, `text`,
204
+ `textSecondary`, `textTertiary`, `accent`, `accentContrast`, `success`,
205
+ `danger`. Read the active palette in your own components with
206
+ `useMotionPalette()`.
207
+
208
+ ## Customization and accessibility
209
+
210
+ - Icons, fonts, colors and timing are props on every component; nothing is
211
+ tied to a brand or an icon set.
212
+ - When the system's reduce motion setting is on, every component settles to a
213
+ static final state.
214
+
163
215
  ## Development
164
216
 
165
217
  ```bash
@@ -172,4 +224,4 @@ Changes that affect the package need a changeset: `npm run changeset`.
172
224
 
173
225
  ## License
174
226
 
175
- MIT.
227
+ [MIT](LICENSE)
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
 
3
- import { isValidElement, memo, useEffect, useMemo } from "react";
3
+ import { isValidElement, memo, useEffect, useMemo, useState } from "react";
4
4
  import { StyleSheet, Text, View } from "react-native";
5
- import Animated, { cancelAnimation, Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming } from "react-native-reanimated";
5
+ import Animated, { cancelAnimation, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming } from "react-native-reanimated";
6
+ import { withAlpha } from "../internal/color.js";
6
7
  import { useMotionPalette } from "../theme/provider.js";
7
8
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
9
  const DEFAULT_TEXT_STYLE = {
@@ -10,14 +11,85 @@ const DEFAULT_TEXT_STYLE = {
10
11
  lineHeight: 24
11
12
  };
12
13
  const DEFAULT_FADE = 360;
13
- const DEFAULT_RISE = 6;
14
+ const FRAME_MS = 32;
14
15
  const CARET_BLINK = 530;
15
- function tokenize(paragraph) {
16
- const parts = paragraph.match(/\S+\s*/g) ?? [];
17
- return parts.map((value, key) => ({
18
- value,
19
- key
20
- }));
16
+ function easeOut(progress) {
17
+ const clamped = Math.min(1, Math.max(0, progress));
18
+ return 1 - (1 - clamped) ** 3;
19
+ }
20
+ function commonPrefix(a, b) {
21
+ const limit = Math.min(a.length, b.length);
22
+ let index = 0;
23
+ while (index < limit && a.charCodeAt(index) === b.charCodeAt(index)) index += 1;
24
+ return index;
25
+ }
26
+ function paragraphRanges(text) {
27
+ const ranges = [];
28
+ const breaks = /\n{2,}/g;
29
+ let start = 0;
30
+ let match = breaks.exec(text);
31
+ while (match) {
32
+ ranges.push({
33
+ start,
34
+ end: match.index
35
+ });
36
+ start = match.index + match[0].length;
37
+ match = breaks.exec(text);
38
+ }
39
+ ranges.push({
40
+ start,
41
+ end: text.length
42
+ });
43
+ return ranges;
44
+ }
45
+ function settledReveal(text) {
46
+ return {
47
+ text,
48
+ settled: text.length,
49
+ chunks: []
50
+ };
51
+ }
52
+ function nextReveal(previous, text, born) {
53
+ if (text === previous.text) return previous;
54
+ const common = commonPrefix(previous.text, text);
55
+ const chunks = previous.chunks.filter(chunk => chunk.start < common).map(chunk => chunk.end > common ? {
56
+ ...chunk,
57
+ end: common
58
+ } : chunk);
59
+ if (text.length > common) chunks.push({
60
+ start: common,
61
+ end: text.length,
62
+ born
63
+ });
64
+ return {
65
+ text,
66
+ settled: Math.min(previous.settled, common),
67
+ chunks
68
+ };
69
+ }
70
+ function revealPieces(reveal, length, now, fadeDuration) {
71
+ const pieces = [];
72
+ if (reveal.settled > 0) pieces.push({
73
+ start: 0,
74
+ end: reveal.settled,
75
+ alpha: 1
76
+ });
77
+ let cursor = reveal.settled;
78
+ for (const chunk of reveal.chunks) {
79
+ const end = Math.min(chunk.end, length);
80
+ if (end > cursor) pieces.push({
81
+ start: cursor,
82
+ end,
83
+ alpha: easeOut((now - chunk.born) / fadeDuration)
84
+ });
85
+ cursor = Math.max(cursor, end);
86
+ }
87
+ if (cursor < length) pieces.push({
88
+ start: cursor,
89
+ end: length,
90
+ alpha: 0
91
+ });
92
+ return pieces;
21
93
  }
22
94
  export function StreamingText({
23
95
  text,
@@ -25,17 +97,53 @@ export function StreamingText({
25
97
  textStyle = DEFAULT_TEXT_STYLE,
26
98
  paragraphGap = 12,
27
99
  fadeDuration = DEFAULT_FADE,
28
- rise = DEFAULT_RISE,
29
100
  caret = true,
101
+ selectable = false,
30
102
  colors,
31
103
  style
32
104
  }) {
33
105
  const palette = useMotionPalette(colors);
34
106
  const reduceMotion = useReducedMotion();
35
- const paragraphs = useMemo(() => text.split(/\n{2,}/), [text]);
36
- const animate = streaming && !reduceMotion;
107
+ const animate = !reduceMotion && fadeDuration > 0;
108
+ const [reveal, setReveal] = useState(() => streaming && animate ? settledReveal("") : settledReveal(text));
109
+ const [now, setNow] = useState(() => Date.now());
110
+ const fading = reveal.chunks.length > 0;
37
111
  const color = palette.text;
38
- const caretNode = caret === false ? null : /*#__PURE__*/isValidElement(caret) ? caret : /*#__PURE__*/_jsx(Caret, {
112
+ useEffect(() => {
113
+ if (!streaming || !animate) {
114
+ setReveal(previous => previous.text === text ? previous : settledReveal(text));
115
+ return;
116
+ }
117
+ const born = Date.now();
118
+ setNow(born);
119
+ setReveal(previous => nextReveal(previous, text, born));
120
+ }, [text, streaming, animate]);
121
+ useEffect(() => {
122
+ if (!fading) return;
123
+ const timer = setInterval(() => {
124
+ const time = Date.now();
125
+ setNow(time);
126
+ setReveal(previous => {
127
+ const live = previous.chunks.filter(chunk => time - chunk.born < fadeDuration);
128
+ if (live.length === previous.chunks.length) return previous;
129
+ const done = previous.chunks[previous.chunks.length - live.length - 1];
130
+ return {
131
+ ...previous,
132
+ settled: Math.max(previous.settled, done.end),
133
+ chunks: live
134
+ };
135
+ });
136
+ }, FRAME_MS);
137
+ return () => clearInterval(timer);
138
+ }, [fading, fadeDuration]);
139
+ const current = streaming ? nextReveal(reveal, text, Number.POSITIVE_INFINITY) : reveal.text === text ? reveal : settledReveal(text);
140
+ const pieces = animate ? revealPieces(current, text.length, now, fadeDuration) : [{
141
+ start: 0,
142
+ end: text.length,
143
+ alpha: 1
144
+ }];
145
+ const paragraphs = useMemo(() => paragraphRanges(text), [text]);
146
+ const caretNode = !streaming || caret === false ? null : /*#__PURE__*/isValidElement(caret) ? caret : /*#__PURE__*/_jsx(Caret, {
39
147
  color: color,
40
148
  blink: !reduceMotion,
41
149
  textStyle: textStyle
@@ -44,60 +152,48 @@ export function StreamingText({
44
152
  style: [{
45
153
  gap: paragraphGap
46
154
  }, style],
47
- children: paragraphs.map((paragraph, index) => {
155
+ children: paragraphs.map((range, index) => {
48
156
  const last = index === paragraphs.length - 1;
49
- if (!animate || !last) {
50
- return /*#__PURE__*/_jsxs(Text, {
51
- style: [textStyle, {
52
- color
53
- }],
54
- children: [paragraph, last && streaming && caretNode]
55
- }, index);
56
- }
57
- return /*#__PURE__*/_jsxs(View, {
58
- accessible: true,
59
- accessibilityRole: "text",
60
- accessibilityLabel: paragraph,
61
- style: {
62
- flexDirection: "row",
63
- flexWrap: "wrap",
64
- alignItems: "flex-end"
65
- },
66
- children: [tokenize(paragraph).map(token => /*#__PURE__*/_jsx(StreamingWord, {
67
- value: token.value,
157
+ const inside = pieces.filter(piece => piece.end > range.start && piece.start < range.end);
158
+ const plain = inside.length <= 1 && (inside[0]?.alpha ?? 1) >= 1;
159
+ if (plain && !last) {
160
+ return /*#__PURE__*/_jsx(PlainParagraph, {
161
+ value: text.slice(range.start, range.end),
68
162
  textStyle: textStyle,
69
163
  color: color,
70
- fadeDuration: fadeDuration,
71
- rise: rise
72
- }, token.key)), caretNode]
164
+ selectable: selectable
165
+ }, index);
166
+ }
167
+ return /*#__PURE__*/_jsxs(Text, {
168
+ style: [textStyle, {
169
+ color
170
+ }],
171
+ selectable: selectable,
172
+ children: [inside.map(piece => {
173
+ const value = text.slice(Math.max(piece.start, range.start), Math.min(piece.end, range.end));
174
+ if (piece.alpha >= 1) return value;
175
+ return /*#__PURE__*/_jsx(Text, {
176
+ style: {
177
+ color: withAlpha(color, piece.alpha)
178
+ },
179
+ children: value
180
+ }, piece.start);
181
+ }), last && caretNode]
73
182
  }, index);
74
183
  })
75
184
  });
76
185
  }
77
- const StreamingWord = /*#__PURE__*/memo(function StreamingWord({
186
+ const PlainParagraph = /*#__PURE__*/memo(function PlainParagraph({
78
187
  value,
79
188
  textStyle,
80
189
  color,
81
- fadeDuration,
82
- rise
190
+ selectable
83
191
  }) {
84
- const progress = useSharedValue(0);
85
- useEffect(() => {
86
- progress.value = withTiming(1, {
87
- duration: fadeDuration,
88
- easing: Easing.out(Easing.cubic)
89
- });
90
- }, [fadeDuration, progress]);
91
- const animatedStyle = useAnimatedStyle(() => ({
92
- opacity: progress.value,
93
- transform: [{
94
- translateY: rise * (1 - progress.value)
95
- }]
96
- }));
97
- return /*#__PURE__*/_jsx(Animated.Text, {
192
+ return /*#__PURE__*/_jsx(Text, {
98
193
  style: [textStyle, {
99
194
  color
100
- }, animatedStyle],
195
+ }],
196
+ selectable: selectable,
101
197
  children: value
102
198
  });
103
199
  });
@@ -1 +1 @@
1
- {"version":3,"names":["isValidElement","memo","useEffect","useMemo","StyleSheet","Text","View","Animated","cancelAnimation","Easing","useAnimatedStyle","useReducedMotion","useSharedValue","withRepeat","withSequence","withTiming","useMotionPalette","jsx","_jsx","jsxs","_jsxs","DEFAULT_TEXT_STYLE","fontSize","lineHeight","DEFAULT_FADE","DEFAULT_RISE","CARET_BLINK","tokenize","paragraph","parts","match","map","value","key","StreamingText","text","streaming","textStyle","paragraphGap","fadeDuration","rise","caret","colors","style","palette","reduceMotion","paragraphs","split","animate","color","caretNode","Caret","blink","gap","children","index","last","length","accessible","accessibilityRole","accessibilityLabel","flexDirection","flexWrap","alignItems","token","StreamingWord","progress","duration","easing","out","cubic","animatedStyle","opacity","transform","translateY","flat","flatten","height","Math","round","width","borderRadius","backgroundColor","marginLeft","marginBottom"],"sourceRoot":"../../../src","sources":["text/streaming-text.tsx"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["isValidElement","memo","useEffect","useMemo","useState","StyleSheet","Text","View","Animated","cancelAnimation","useAnimatedStyle","useReducedMotion","useSharedValue","withRepeat","withSequence","withTiming","withAlpha","useMotionPalette","jsx","_jsx","jsxs","_jsxs","DEFAULT_TEXT_STYLE","fontSize","lineHeight","DEFAULT_FADE","FRAME_MS","CARET_BLINK","easeOut","progress","clamped","Math","min","max","commonPrefix","a","b","limit","length","index","charCodeAt","paragraphRanges","text","ranges","breaks","start","match","exec","push","end","settledReveal","settled","chunks","nextReveal","previous","born","common","filter","chunk","map","revealPieces","reveal","now","fadeDuration","pieces","alpha","cursor","StreamingText","streaming","textStyle","paragraphGap","caret","selectable","colors","style","palette","reduceMotion","animate","setReveal","setNow","Date","fading","color","timer","setInterval","time","live","done","clearInterval","current","Number","POSITIVE_INFINITY","paragraphs","caretNode","Caret","blink","gap","children","range","last","inside","piece","plain","PlainParagraph","value","slice","opacity","flat","flatten","height","round","duration","animatedStyle","width","borderRadius","backgroundColor","marginLeft","marginBottom"],"sourceRoot":"../../../src","sources":["text/streaming-text.tsx"],"mappings":"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","ignoreList":[]}
@@ -7,10 +7,10 @@ export type StreamingTextProps = {
7
7
  textStyle?: StyleProp<TextStyle>;
8
8
  paragraphGap?: number;
9
9
  fadeDuration?: number;
10
- rise?: number;
11
10
  caret?: boolean | ReactNode;
11
+ selectable?: boolean;
12
12
  colors?: Partial<MotionPalette>;
13
13
  style?: StyleProp<ViewStyle>;
14
14
  };
15
- export declare function StreamingText({ text, streaming, textStyle, paragraphGap, fadeDuration, rise, caret, colors, style, }: StreamingTextProps): import("react").JSX.Element;
15
+ export declare function StreamingText({ text, streaming, textStyle, paragraphGap, fadeDuration, caret, selectable, colors, style, }: StreamingTextProps): import("react").JSX.Element;
16
16
  //# sourceMappingURL=streaming-text.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"streaming-text.d.ts","sourceRoot":"","sources":["../../../../src/text/streaming-text.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA4C,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACjF,OAAO,EAA0B,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAWtG,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,qBAAkB,CAAC;AAQtD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,MAAM,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IAChC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,CAAC;AASF,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,SAAS,EACT,SAA8B,EAC9B,YAAiB,EACjB,YAA2B,EAC3B,IAAmB,EACnB,KAAY,EACZ,MAAM,EACN,KAAK,GACN,EAAE,kBAAkB,+BA4CpB"}
1
+ {"version":3,"file":"streaming-text.d.ts","sourceRoot":"","sources":["../../../../src/text/streaming-text.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAsD,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC3F,OAAO,EAA0B,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAWtG,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,qBAAkB,CAAC;AAQtD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IAChC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,CAAC;AA+DF,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,SAAS,EACT,SAA8B,EAC9B,YAAiB,EACjB,YAA2B,EAC3B,KAAY,EACZ,UAAkB,EAClB,MAAM,EACN,KAAK,GACN,EAAE,kBAAkB,+BA0EpB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-ai-animations",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Animated React Native and Expo components for AI chat apps: streaming text, shimmer loading text, thinking and status indicators, voice orbs and waveforms, image and video generation loaders, question cards and reasoning effort bars. Built on Reanimated and Skia.",
5
5
  "license": "MIT",
6
6
  "author": "Ahmet Kar",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/AhmeetKaar/nami-motion.git"
10
10
  },
11
- "homepage": "https://github.com/AhmeetKaar/nami-motion#readme",
11
+ "homepage": "https://nami-motion-web.vercel.app/",
12
12
  "keywords": [
13
13
  "react-native",
14
14
  "expo",
@@ -1,8 +1,7 @@
1
- import { isValidElement, memo, useEffect, useMemo, type ReactNode } from "react";
1
+ import { isValidElement, memo, useEffect, useMemo, useState, type ReactNode } from "react";
2
2
  import { StyleSheet, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
3
3
  import Animated, {
4
4
  cancelAnimation,
5
- Easing,
6
5
  useAnimatedStyle,
7
6
  useReducedMotion,
8
7
  useSharedValue,
@@ -10,12 +9,13 @@ import Animated, {
10
9
  withSequence,
11
10
  withTiming,
12
11
  } from "react-native-reanimated";
12
+ import { withAlpha } from "../internal/color";
13
13
  import { type MotionPalette } from "../theme/palette";
14
14
  import { useMotionPalette } from "../theme/provider";
15
15
 
16
16
  const DEFAULT_TEXT_STYLE: TextStyle = { fontSize: 16, lineHeight: 24 };
17
17
  const DEFAULT_FADE = 360;
18
- const DEFAULT_RISE = 6;
18
+ const FRAME_MS = 32;
19
19
  const CARET_BLINK = 530;
20
20
 
21
21
  export type StreamingTextProps = {
@@ -24,17 +24,71 @@ export type StreamingTextProps = {
24
24
  textStyle?: StyleProp<TextStyle>;
25
25
  paragraphGap?: number;
26
26
  fadeDuration?: number;
27
- rise?: number;
28
27
  caret?: boolean | ReactNode;
28
+ selectable?: boolean;
29
29
  colors?: Partial<MotionPalette>;
30
30
  style?: StyleProp<ViewStyle>;
31
31
  };
32
32
 
33
- type Token = { value: string; key: number };
33
+ type Chunk = { start: number; end: number; born: number };
34
34
 
35
- function tokenize(paragraph: string): Token[] {
36
- const parts = paragraph.match(/\S+\s*/g) ?? [];
37
- return parts.map((value, key) => ({ value, key }));
35
+ type Reveal = { text: string; settled: number; chunks: Chunk[] };
36
+
37
+ type Range = { start: number; end: number };
38
+
39
+ type Piece = Range & { alpha: number };
40
+
41
+ function easeOut(progress: number) {
42
+ const clamped = Math.min(1, Math.max(0, progress));
43
+ return 1 - (1 - clamped) ** 3;
44
+ }
45
+
46
+ function commonPrefix(a: string, b: string) {
47
+ const limit = Math.min(a.length, b.length);
48
+ let index = 0;
49
+ while (index < limit && a.charCodeAt(index) === b.charCodeAt(index)) index += 1;
50
+ return index;
51
+ }
52
+
53
+ function paragraphRanges(text: string): Range[] {
54
+ const ranges: Range[] = [];
55
+ const breaks = /\n{2,}/g;
56
+ let start = 0;
57
+ let match = breaks.exec(text);
58
+ while (match) {
59
+ ranges.push({ start, end: match.index });
60
+ start = match.index + match[0].length;
61
+ match = breaks.exec(text);
62
+ }
63
+ ranges.push({ start, end: text.length });
64
+ return ranges;
65
+ }
66
+
67
+ function settledReveal(text: string): Reveal {
68
+ return { text, settled: text.length, chunks: [] };
69
+ }
70
+
71
+ function nextReveal(previous: Reveal, text: string, born: number): Reveal {
72
+ if (text === previous.text) return previous;
73
+ const common = commonPrefix(previous.text, text);
74
+ const chunks = previous.chunks
75
+ .filter((chunk) => chunk.start < common)
76
+ .map((chunk) => (chunk.end > common ? { ...chunk, end: common } : chunk));
77
+ if (text.length > common) chunks.push({ start: common, end: text.length, born });
78
+ return { text, settled: Math.min(previous.settled, common), chunks };
79
+ }
80
+
81
+ function revealPieces(reveal: Reveal, length: number, now: number, fadeDuration: number): Piece[] {
82
+ const pieces: Piece[] = [];
83
+ if (reveal.settled > 0) pieces.push({ start: 0, end: reveal.settled, alpha: 1 });
84
+ let cursor = reveal.settled;
85
+ for (const chunk of reveal.chunks) {
86
+ const end = Math.min(chunk.end, length);
87
+ if (end > cursor) pieces.push({ start: cursor, end, alpha: easeOut((now - chunk.born) / fadeDuration) });
88
+ cursor = Math.max(cursor, end);
89
+ }
90
+ if (cursor < length) pieces.push({ start: cursor, end: length, alpha: 0 });
91
+ return pieces;
38
92
  }
39
93
 
40
94
  export function StreamingText({
@@ -43,77 +97,99 @@ export function StreamingText({
43
97
  textStyle = DEFAULT_TEXT_STYLE,
44
98
  paragraphGap = 12,
45
99
  fadeDuration = DEFAULT_FADE,
46
- rise = DEFAULT_RISE,
47
100
  caret = true,
101
+ selectable = false,
48
102
  colors,
49
103
  style,
50
104
  }: StreamingTextProps) {
51
105
  const palette = useMotionPalette(colors);
52
106
  const reduceMotion = useReducedMotion();
53
- const paragraphs = useMemo(() => text.split(/\n{2,}/), [text]);
54
- const animate = streaming && !reduceMotion;
107
+ const animate = !reduceMotion && fadeDuration > 0;
108
+ const [reveal, setReveal] = useState<Reveal>(() => (streaming && animate ? settledReveal("") : settledReveal(text)));
109
+ const [now, setNow] = useState(() => Date.now());
110
+ const fading = reveal.chunks.length > 0;
55
111
  const color = palette.text;
56
- const caretNode = caret === false ? null : isValidElement(caret) ? caret : <Caret color={color} blink={!reduceMotion} textStyle={textStyle} />;
112
+
113
+ useEffect(() => {
114
+ if (!streaming || !animate) {
115
+ setReveal((previous) => (previous.text === text ? previous : settledReveal(text)));
116
+ return;
117
+ }
118
+ const born = Date.now();
119
+ setNow(born);
120
+ setReveal((previous) => nextReveal(previous, text, born));
121
+ }, [text, streaming, animate]);
122
+
123
+ useEffect(() => {
124
+ if (!fading) return;
125
+ const timer = setInterval(() => {
126
+ const time = Date.now();
127
+ setNow(time);
128
+ setReveal((previous) => {
129
+ const live = previous.chunks.filter((chunk) => time - chunk.born < fadeDuration);
130
+ if (live.length === previous.chunks.length) return previous;
131
+ const done = previous.chunks[previous.chunks.length - live.length - 1];
132
+ return { ...previous, settled: Math.max(previous.settled, done.end), chunks: live };
133
+ });
134
+ }, FRAME_MS);
135
+ return () => clearInterval(timer);
136
+ }, [fading, fadeDuration]);
137
+
138
+ const current = streaming ? nextReveal(reveal, text, Number.POSITIVE_INFINITY) : reveal.text === text ? reveal : settledReveal(text);
139
+ const pieces = animate ? revealPieces(current, text.length, now, fadeDuration) : [{ start: 0, end: text.length, alpha: 1 }];
140
+ const paragraphs = useMemo(() => paragraphRanges(text), [text]);
141
+ const caretNode =
142
+ !streaming || caret === false ? null : isValidElement(caret) ? caret : <Caret color={color} blink={!reduceMotion} textStyle={textStyle} />;
57
143
 
58
144
  return (
59
145
  <View style={[{ gap: paragraphGap }, style]}>
60
- {paragraphs.map((paragraph, index) => {
146
+ {paragraphs.map((range, index) => {
61
147
  const last = index === paragraphs.length - 1;
62
- if (!animate || !last) {
148
+ const inside = pieces.filter((piece) => piece.end > range.start && piece.start < range.end);
149
+ const plain = inside.length <= 1 && (inside[0]?.alpha ?? 1) >= 1;
150
+ if (plain && !last) {
63
151
  return (
64
- <Text key={index} style={[textStyle, { color }]}>
65
- {paragraph}
66
- {last && streaming && caretNode}
67
- </Text>
152
+ <PlainParagraph
153
+ key={index}
154
+ value={text.slice(range.start, range.end)}
155
+ textStyle={textStyle}
156
+ color={color}
157
+ selectable={selectable}
158
+ />
68
159
  );
69
160
  }
70
161
  return (
71
- <View
72
- key={index}
73
- accessible
74
- accessibilityRole="text"
75
- accessibilityLabel={paragraph}
76
- style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "flex-end" }}
77
- >
78
- {tokenize(paragraph).map((token) => (
79
- <StreamingWord
80
- key={token.key}
81
- value={token.value}
82
- textStyle={textStyle}
83
- color={color}
84
- fadeDuration={fadeDuration}
85
- rise={rise}
86
- />
87
- ))}
88
- {caretNode}
89
- </View>
162
+ <Text key={index} style={[textStyle, { color }]} selectable={selectable}>
163
+ {inside.map((piece) => {
164
+ const value = text.slice(Math.max(piece.start, range.start), Math.min(piece.end, range.end));
165
+ if (piece.alpha >= 1) return value;
166
+ return (
167
+ <Text key={piece.start} style={{ color: withAlpha(color, piece.alpha) }}>
168
+ {value}
169
+ </Text>
170
+ );
171
+ })}
172
+ {last && caretNode}
173
+ </Text>
90
174
  );
91
175
  })}
92
176
  </View>
93
177
  );
94
178
  }
95
179
 
96
- type StreamingWordProps = {
180
+ type PlainParagraphProps = {
97
181
  value: string;
98
182
  textStyle: StyleProp<TextStyle>;
99
183
  color: string;
100
- fadeDuration: number;
101
- rise: number;
184
+ selectable: boolean;
102
185
  };
103
186
 
104
- const StreamingWord = memo(function StreamingWord({ value, textStyle, color, fadeDuration, rise }: StreamingWordProps) {
105
- const progress = useSharedValue(0);
106
-
107
- useEffect(() => {
108
- progress.value = withTiming(1, { duration: fadeDuration, easing: Easing.out(Easing.cubic) });
109
- }, [fadeDuration, progress]);
110
-
111
- const animatedStyle = useAnimatedStyle(() => ({
112
- opacity: progress.value,
113
- transform: [{ translateY: rise * (1 - progress.value) }],
114
- }));
115
-
116
- return <Animated.Text style={[textStyle, { color }, animatedStyle]}>{value}</Animated.Text>;
187
+ const PlainParagraph = memo(function PlainParagraph({ value, textStyle, color, selectable }: PlainParagraphProps) {
188
+ return (
189
+ <Text style={[textStyle, { color }]} selectable={selectable}>
190
+ {value}
191
+ </Text>
192
+ );
117
193
  });
118
194
 
119
195
  function Caret({ color, blink, textStyle }: { color: string; blink: boolean; textStyle: StyleProp<TextStyle> }) {