@expokit/cli 0.4.3 → 0.5.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.
Files changed (242) hide show
  1. package/README.md +30 -3
  2. package/bin/expo-kit.js +749 -288
  3. package/bin/lib.mjs +313 -4
  4. package/package.json +1 -1
  5. package/registry/_docs/action-sheet.json +1 -1
  6. package/registry/_docs/alert.json +6 -0
  7. package/registry/_docs/bottom-sheet.json +6 -0
  8. package/registry/_docs/bullet.json +6 -0
  9. package/registry/_docs/dialog.json +1 -1
  10. package/registry/_docs/drawer.json +3 -3
  11. package/registry/_docs/flip-calendar.json +106 -0
  12. package/registry/_docs/glass-search-bar.json +1 -1
  13. package/registry/_docs/grain.json +6 -1
  14. package/registry/_docs/isometric-bar.json +6 -0
  15. package/registry/_docs/liquidglass-tab.json +1 -1
  16. package/registry/_docs/password-input.json +1 -1
  17. package/registry/_docs/paywall.json +144 -0
  18. package/registry/_docs/pixel-bar.json +6 -0
  19. package/registry/_docs/polar-area.json +6 -0
  20. package/registry/_docs/qr-code-button.json +9 -3
  21. package/registry/_docs/ruler-time-picker.json +147 -0
  22. package/registry/_docs/scratch-card.json +7 -1
  23. package/registry/_docs/search-bar.json +1 -1
  24. package/registry/_docs/select.json +2 -2
  25. package/registry/_docs/slide-to-confirm.json +2 -2
  26. package/registry/_docs/spinner.json +1 -1
  27. package/registry/_docs/stateful-button.json +2 -2
  28. package/registry/_docs/swipe-deck.json +1 -1
  29. package/registry/_docs/time-picker.json +2 -2
  30. package/registry/_docs/timeline-chart.json +6 -0
  31. package/registry/_docs/toast.json +15 -3
  32. package/registry/_docs/treemap.json +6 -0
  33. package/registry/_docs/waterfall.json +6 -0
  34. package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
  35. package/registry/accordion/files/src/lib/kit.tsx +462 -12
  36. package/registry/accordion.json +1 -0
  37. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +50 -58
  38. package/registry/action-sheet/files/src/lib/kit.tsx +462 -12
  39. package/registry/action-sheet.json +1 -0
  40. package/registry/alert/files/src/components/ui/alert.tsx +20 -13
  41. package/registry/alert/files/src/lib/kit.tsx +462 -12
  42. package/registry/alert.json +3 -4
  43. package/registry/assistant-app.json +1 -0
  44. package/registry/avatar/files/src/components/ui/avatar.tsx +89 -74
  45. package/registry/avatar/files/src/lib/kit.tsx +462 -12
  46. package/registry/avatar.json +1 -0
  47. package/registry/badge/files/src/components/ui/badge.tsx +36 -36
  48. package/registry/badge/files/src/lib/kit.tsx +462 -12
  49. package/registry/badge.json +1 -0
  50. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +39 -44
  51. package/registry/bottom-nav/files/src/lib/kit.tsx +462 -12
  52. package/registry/bottom-nav.json +5 -0
  53. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +357 -300
  54. package/registry/bottom-sheet/files/src/lib/kit.tsx +462 -12
  55. package/registry/bottom-sheet.json +5 -1
  56. package/registry/bullet.json +1 -0
  57. package/registry/button/files/src/components/ui/button.tsx +23 -34
  58. package/registry/button/files/src/lib/kit.tsx +462 -12
  59. package/registry/button-3d/files/src/components/ui/button-3d.tsx +6 -9
  60. package/registry/button-3d/files/src/lib/kit.tsx +462 -12
  61. package/registry/button-3d.json +1 -0
  62. package/registry/button.json +1 -0
  63. package/registry/calendar/files/src/components/ui/calendar.tsx +77 -79
  64. package/registry/calendar/files/src/lib/kit.tsx +462 -12
  65. package/registry/calendar.json +1 -0
  66. package/registry/card/files/src/components/ui/card.tsx +7 -6
  67. package/registry/card/files/src/lib/kit.tsx +462 -12
  68. package/registry/card-stack.json +1 -0
  69. package/registry/card.json +1 -0
  70. package/registry/carousel.json +1 -0
  71. package/registry/chat.json +7 -0
  72. package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
  73. package/registry/checkbox/files/src/lib/kit.tsx +462 -12
  74. package/registry/checkbox.json +1 -0
  75. package/registry/checkout.json +1 -0
  76. package/registry/chip-group/files/src/components/ui/chip-group.tsx +24 -24
  77. package/registry/chip-group/files/src/lib/kit.tsx +462 -12
  78. package/registry/chip-group.json +1 -0
  79. package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
  80. package/registry/confetti.json +3 -6
  81. package/registry/coupon/files/src/components/ui/coupon.tsx +47 -52
  82. package/registry/coupon/files/src/components/ui/flip-card.tsx +9 -14
  83. package/registry/coupon/files/src/components/ui/tilt-card.tsx +80 -101
  84. package/registry/coupon/files/src/lib/kit.tsx +462 -12
  85. package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
  86. package/registry/coupon.json +1 -0
  87. package/registry/cube-pager.json +1 -0
  88. package/registry/dashboard.json +1 -0
  89. package/registry/date-picker/files/src/components/ui/calendar.tsx +77 -79
  90. package/registry/date-picker/files/src/components/ui/date-picker.tsx +64 -71
  91. package/registry/date-picker/files/src/lib/kit.tsx +462 -12
  92. package/registry/date-picker.json +1 -0
  93. package/registry/dialog/files/src/components/ui/dialog.tsx +66 -68
  94. package/registry/dialog/files/src/lib/kit.tsx +462 -12
  95. package/registry/dialog.json +1 -0
  96. package/registry/donut-shop.json +1 -0
  97. package/registry/drawer/files/src/components/ui/drawer.tsx +76 -88
  98. package/registry/drawer/files/src/lib/kit.tsx +462 -12
  99. package/registry/drawer.json +1 -0
  100. package/registry/fab.json +1 -0
  101. package/registry/fintech-app.json +1 -0
  102. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +632 -0
  103. package/registry/flip-calendar/files/src/lib/kit.tsx +672 -0
  104. package/registry/flip-calendar.json +24 -0
  105. package/registry/flip-card/files/src/components/ui/flip-card.tsx +9 -14
  106. package/registry/flip-card/files/src/lib/kit.tsx +462 -12
  107. package/registry/flip-card.json +1 -0
  108. package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
  109. package/registry/floating-input/files/src/lib/kit.tsx +462 -12
  110. package/registry/floating-input.json +1 -0
  111. package/registry/food-app.json +1 -0
  112. package/registry/gift-card.json +1 -0
  113. package/registry/glass-button.json +5 -0
  114. package/registry/glass-nav-bar.json +1 -0
  115. package/registry/glass-search-bar.json +1 -0
  116. package/registry/glass-segmented.json +1 -0
  117. package/registry/glass-sheet.json +1 -0
  118. package/registry/glass-tab-bar.json +5 -0
  119. package/registry/glass.json +1 -0
  120. package/registry/grain/files/src/components/ui/grain.tsx +16 -14
  121. package/registry/grain.json +8 -6
  122. package/registry/inbox.json +1 -0
  123. package/registry/isometric-bar.json +1 -0
  124. package/registry/kit/files/src/lib/kit.tsx +462 -12
  125. package/registry/kit.json +1 -0
  126. package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
  127. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +29 -16
  128. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +33 -19
  129. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
  130. package/registry/liquidglass-tab.json +1 -0
  131. package/registry/list-item/files/src/components/ui/list-item.tsx +43 -43
  132. package/registry/list-item/files/src/lib/kit.tsx +462 -12
  133. package/registry/list-item.json +1 -0
  134. package/registry/loyalty-card.json +1 -0
  135. package/registry/morphing-button-group.json +1 -0
  136. package/registry/number-ticker.json +1 -0
  137. package/registry/offers.json +1 -0
  138. package/registry/onboarding.json +1 -0
  139. package/registry/orb-reveal.json +1 -0
  140. package/registry/otp-input/files/src/components/ui/otp-input.tsx +49 -30
  141. package/registry/otp-input/files/src/lib/kit.tsx +462 -12
  142. package/registry/otp-input.json +1 -0
  143. package/registry/parallax-layers.json +1 -0
  144. package/registry/parallax-scroll.json +1 -0
  145. package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
  146. package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
  147. package/registry/password-input/files/src/lib/kit.tsx +462 -12
  148. package/registry/password-input.json +1 -0
  149. package/registry/payment-card/files/src/components/ui/flip-card.tsx +9 -14
  150. package/registry/payment-card/files/src/components/ui/payment-card.tsx +26 -20
  151. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +80 -101
  152. package/registry/payment-card/files/src/lib/kit.tsx +462 -12
  153. package/registry/payment-card.json +1 -0
  154. package/registry/paywall.json +23 -0
  155. package/registry/pixel-bar.json +1 -0
  156. package/registry/polar-area.json +1 -0
  157. package/registry/profile.json +1 -0
  158. package/registry/progress/files/src/components/ui/progress.tsx +31 -29
  159. package/registry/progress/files/src/lib/kit.tsx +462 -12
  160. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +21 -21
  161. package/registry/progress-steps/files/src/lib/kit.tsx +462 -12
  162. package/registry/progress-steps.json +1 -0
  163. package/registry/progress.json +1 -0
  164. package/registry/qr-code-button.json +1 -0
  165. package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
  166. package/registry/radio-group/files/src/lib/kit.tsx +462 -12
  167. package/registry/radio-group.json +1 -0
  168. package/registry/rating.json +1 -0
  169. package/registry/ring-carousel.json +1 -0
  170. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +832 -0
  171. package/registry/ruler-time-picker/files/src/lib/kit.tsx +672 -0
  172. package/registry/ruler-time-picker.json +26 -0
  173. package/registry/scratch-card.json +1 -0
  174. package/registry/search-bar/files/src/components/ui/search-bar.tsx +28 -28
  175. package/registry/search-bar/files/src/lib/kit.tsx +462 -12
  176. package/registry/search-bar.json +1 -0
  177. package/registry/search-results.json +4 -0
  178. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +11 -17
  179. package/registry/segmented-control/files/src/lib/kit.tsx +462 -12
  180. package/registry/segmented-control.json +1 -0
  181. package/registry/select/files/src/components/ui/select.tsx +107 -104
  182. package/registry/select/files/src/lib/kit.tsx +462 -12
  183. package/registry/select.json +6 -0
  184. package/registry/settings.json +1 -0
  185. package/registry/sign-in.json +1 -0
  186. package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
  187. package/registry/skeleton/files/src/lib/kit.tsx +462 -12
  188. package/registry/skeleton.json +1 -0
  189. package/registry/slide-to-confirm.json +1 -0
  190. package/registry/slider/files/src/components/ui/slider.tsx +106 -108
  191. package/registry/slider/files/src/lib/kit.tsx +462 -12
  192. package/registry/slider.json +5 -0
  193. package/registry/spin-wheel.json +1 -0
  194. package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
  195. package/registry/spinner/files/src/lib/kit.tsx +462 -12
  196. package/registry/spinner.json +6 -0
  197. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
  198. package/registry/stateful-button/files/src/lib/kit.tsx +462 -12
  199. package/registry/stateful-button.json +5 -0
  200. package/registry/stepper/files/src/components/ui/stepper.tsx +40 -38
  201. package/registry/stepper/files/src/lib/kit.tsx +462 -12
  202. package/registry/stepper.json +1 -0
  203. package/registry/swipe-deck.json +1 -0
  204. package/registry/swipeable-row.json +4 -0
  205. package/registry/switch/files/src/components/ui/switch.tsx +41 -47
  206. package/registry/switch/files/src/lib/kit.tsx +462 -12
  207. package/registry/switch.json +1 -0
  208. package/registry/tabs/files/src/components/ui/tabs.tsx +61 -64
  209. package/registry/tabs/files/src/lib/kit.tsx +462 -12
  210. package/registry/tabs.json +1 -0
  211. package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
  212. package/registry/text-animation/files/src/lib/kit.tsx +462 -12
  213. package/registry/text-animation.json +7 -0
  214. package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
  215. package/registry/textarea/files/src/lib/kit.tsx +462 -12
  216. package/registry/textarea.json +1 -0
  217. package/registry/ticket-card.json +1 -0
  218. package/registry/ticket-shape/files/src/lib/kit.tsx +462 -12
  219. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
  220. package/registry/ticket-shape.json +1 -0
  221. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +80 -101
  222. package/registry/tilt-card/files/src/lib/kit.tsx +462 -12
  223. package/registry/tilt-card.json +1 -0
  224. package/registry/time-picker/files/src/components/ui/time-picker.tsx +136 -131
  225. package/registry/time-picker/files/src/lib/kit.tsx +462 -12
  226. package/registry/time-picker.json +1 -0
  227. package/registry/timeline/files/src/components/ui/avatar.tsx +89 -74
  228. package/registry/timeline/files/src/components/ui/timeline.tsx +49 -57
  229. package/registry/timeline/files/src/lib/kit.tsx +462 -12
  230. package/registry/timeline-chart.json +1 -0
  231. package/registry/timeline.json +1 -0
  232. package/registry/toast/files/src/components/ui/toast.tsx +543 -465
  233. package/registry/toast/files/src/lib/kit.tsx +462 -12
  234. package/registry/toast.json +7 -5
  235. package/registry/treemap.json +1 -0
  236. package/registry/waterfall.json +1 -0
  237. package/registry/wheel-list.json +1 -0
  238. package/schema/components.schema.json +1 -0
  239. package/registry/alert/files/src/components/ui/button.tsx +0 -303
  240. package/registry/confetti/files/src/lib/kit.tsx +0 -222
  241. package/registry/grain/files/src/lib/kit.tsx +0 -222
  242. package/registry/toast/files/src/components/ui/button.tsx +0 -303
@@ -1,17 +1,24 @@
1
1
  import * as Haptics from 'expo-haptics';
2
2
  import React, { createContext, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
- import {
4
- AccessibilityInfo,
5
- Animated,
3
+ import { AccessibilityInfo, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
4
+ import { GestureDetector, GestureHandlerRootView, usePanGesture } from 'react-native-gesture-handler';
5
+ import Animated, {
6
6
  Easing,
7
- PanResponder,
8
- Pressable,
9
- StyleSheet,
10
- Text,
11
- View,
12
- type StyleProp,
13
- type ViewStyle,
14
- } from 'react-native';
7
+ Extrapolation,
8
+ ReduceMotion,
9
+ cancelAnimation,
10
+ interpolate,
11
+ useAnimatedProps,
12
+ useAnimatedStyle,
13
+ useSharedValue,
14
+ withDelay,
15
+ withRepeat,
16
+ withSequence,
17
+ withSpring,
18
+ withTiming,
19
+ type SharedValue,
20
+ } from 'react-native-reanimated';
21
+ import { scheduleOnRN } from 'react-native-worklets';
15
22
  import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
16
23
 
17
24
  import { alpha, motion, readableOn, useKit, type Colors } from '@/lib/kit';
@@ -27,15 +34,21 @@ export interface ToastAction {
27
34
 
28
35
  export interface ToastOptions {
29
36
  /** Reuse an id to update a toast in place (e.g. loading → success). */
30
- id?: string;
31
- description?: string;
37
+ id?: string | undefined;
38
+
39
+ description?: string | undefined;
40
+
32
41
  /** Milliseconds before it auto-dismisses. `Infinity` keeps it until dismissed. */
33
- duration?: number;
34
- action?: ToastAction;
42
+ duration?: number | undefined;
43
+
44
+ action?: ToastAction | undefined;
45
+
35
46
  /** Replace the type icon (or `false` for none). */
36
- icon?: React.ReactNode | false;
47
+ icon?: React.ReactNode | false | undefined;
48
+
37
49
  /** Send to the `<Toaster id="…">` with this id. Toasts without one go to the Toaster without an id. */
38
- toaster?: string;
50
+ toaster?: string | undefined;
51
+
39
52
  }
40
53
 
41
54
  export interface ToastData extends ToastOptions {
@@ -44,7 +57,8 @@ export interface ToastData extends ToastOptions {
44
57
  title: string;
45
58
  /** Bumped on every update, restarts the timer. */
46
59
  updatedAt: number;
47
- dismissed?: boolean;
60
+ dismissed?: boolean | undefined;
61
+
48
62
  }
49
63
 
50
64
  type Message<T> = string | ((value: T) => string);
@@ -174,6 +188,11 @@ function useReduceMotion() {
174
188
  return reduce;
175
189
  }
176
190
 
191
+ /** Toasts handle Reduce Motion themselves, so Reanimated must not also skip their animations. */
192
+ const KEEP = ReduceMotion.Never;
193
+ const SNAPPY = { ...motion.snappy, reduceMotion: KEEP };
194
+ const BOUNCY = { ...motion.bouncy, reduceMotion: KEEP };
195
+
177
196
  const HAPTIC: Partial<Record<ToastType, Haptics.NotificationFeedbackType>> = {
178
197
  success: Haptics.NotificationFeedbackType.Success,
179
198
  warning: Haptics.NotificationFeedbackType.Warning,
@@ -182,7 +201,7 @@ const HAPTIC: Partial<Record<ToastType, Haptics.NotificationFeedbackType>> = {
182
201
 
183
202
  /** One notification tap when a toast appears or changes tone. After a spinner, it lands as the circle closes. */
184
203
  function useToneHaptic(id: string | undefined, type: ToastType | undefined, enabled: boolean) {
185
- const prev = useRef<{ id?: string; type?: ToastType }>({});
204
+ const prev = useRef<{ id?: string | undefined; type?: ToastType | undefined }>({});
186
205
  useEffect(() => {
187
206
  const from = prev.current;
188
207
  prev.current = { id, type };
@@ -198,11 +217,11 @@ function useToneHaptic(id: string | undefined, type: ToastType | undefined, enab
198
217
  }
199
218
 
200
219
  /**
201
- * Auto-dismiss clock. Drives a native-driven 1 → 0 `life` value (the drain bar) and fires `onDone`.
220
+ * Auto-dismiss clock. Drives a UI-thread 1 → 0 `life` value (the drain bar) and fires `onDone`.
202
221
  * Both freeze while `paused` and resume where they stopped.
203
222
  */
204
223
  function useLifetime(key: number | undefined, duration: number, running: boolean, onDone: () => void) {
205
- const life = useRef(new Animated.Value(1)).current;
224
+ const life = useSharedValue(1);
206
225
  const remaining = useRef(duration);
207
226
  const done = useRef(onDone);
208
227
  useEffect(() => {
@@ -210,24 +229,20 @@ function useLifetime(key: number | undefined, duration: number, running: boolean
210
229
  });
211
230
  useEffect(() => {
212
231
  remaining.current = duration;
213
- life.setValue(1);
232
+ life.set(1);
214
233
  }, [key, duration, life]);
215
234
  useEffect(() => {
216
235
  if (!running || !Number.isFinite(duration) || duration <= 0) return;
217
236
  const startedAt = Date.now();
218
- life.setValue(Math.max(0, remaining.current / duration));
219
- Animated.timing(life, {
220
- toValue: 0,
221
- duration: Math.max(0, remaining.current),
222
- easing: Easing.linear,
223
- useNativeDriver: true,
224
- }).start();
237
+ life.set(Math.max(0, remaining.current / duration));
238
+ // The drain is a clock, not decoration: it runs at full length even with Reduce Motion on.
239
+ life.set(withTiming(0, { duration: Math.max(0, remaining.current), easing: Easing.linear, reduceMotion: KEEP }));
225
240
  const timer = setTimeout(() => done.current(), Math.max(0, remaining.current));
226
241
  return () => {
227
242
  clearTimeout(timer);
228
- life.stopAnimation();
243
+ cancelAnimation(life);
229
244
  remaining.current -= Date.now() - startedAt;
230
- life.setValue(Math.max(0, remaining.current / duration));
245
+ life.set(Math.max(0, remaining.current / duration));
231
246
  };
232
247
  }, [key, duration, running, life]);
233
248
  return life;
@@ -240,33 +255,82 @@ export type ToasterVariant = 'default' | 'rich' | 'minimal' | 'island';
240
255
 
241
256
  export interface ToasterProps {
242
257
  /** Only show toasts sent with `{ toaster: id }`. */
243
- id?: string;
258
+ id?: string | undefined;
259
+
244
260
  /** Edge the stack sits on. Ignored by `variant="island"`, which always lives at the top centre. */
245
- position?: ToasterPosition;
261
+ position?: ToasterPosition | undefined;
262
+
246
263
  /**
247
264
  * 'default' cards with a tone glow · 'rich' cards filled with the tone · 'minimal' compact inverted pills
248
265
  * (a second line for the description when there is one) ·
249
266
  * 'island' a black capsule at the top centre that springs open like the iPhone Dynamic Island.
250
267
  */
251
- variant?: ToasterVariant;
268
+ variant?: ToasterVariant | undefined;
269
+
252
270
  /** Default auto-dismiss time in ms (default 4000). */
253
- duration?: number;
271
+ duration?: number | undefined;
272
+
254
273
  /** How many cards peek out of the stack (default 3). */
255
- visibleToasts?: number;
274
+ visibleToasts?: number | undefined;
275
+
256
276
  /** Always show the stack fanned out instead of collapsed. */
257
- expand?: boolean;
277
+ expand?: boolean | undefined;
278
+
279
+ /** Distance from the top / bottom edge (default 16, island 10), added to `safeArea` and `bottomInset`. */
280
+ offset?: number | undefined;
281
+
258
282
  /**
259
- * Distance from the top / bottom edge (default 16, island 10). There is no safe-area dependency:
260
- * pass `insets.top + 8` (react-native-safe-area-context) to sit the island below a notch.
283
+ * Keep clear of the notch / Dynamic Island and the home indicator: adds the safe-area insets from
284
+ * react-native-safe-area-context when the app has it (every Expo Router app does). Without it, insets are 0.
261
285
  */
262
- offset?: number;
286
+ safeArea?: boolean | undefined;
287
+
288
+ /**
289
+ * Extra space below a bottom stack, for UI that covers the screen's foot — a tab bar, a composer.
290
+ * With a tab bar: `<Toaster safeArea bottomInset={49} />` (49 = the standard bar above the home indicator).
291
+ */
292
+ bottomInset?: number | undefined;
293
+
263
294
  /** Space between fanned-out cards (default 10). */
264
- gap?: number;
295
+ gap?: number | undefined;
296
+
265
297
  /** Show a ✕ on every toast. */
266
- closeButton?: boolean;
298
+ closeButton?: boolean | undefined;
299
+
267
300
  /** Success / warning / error toasts tap the matching notification haptic (default true). */
268
- haptics?: boolean;
269
- style?: StyleProp<ViewStyle>;
301
+ haptics?: boolean | undefined;
302
+
303
+ style?: StyleProp<ViewStyle> | undefined;
304
+
305
+ }
306
+
307
+ interface Insets {
308
+ top: number;
309
+ bottom: number;
310
+ }
311
+
312
+ const NO_INSETS: Insets = { top: 0, bottom: 0 };
313
+
314
+ declare const require: (id: string) => unknown;
315
+
316
+ /** react-native-safe-area-context when installed (Expo's Metro allows a missing optional require), else null. */
317
+ const safeAreaLib = (() => {
318
+ try {
319
+ return require('react-native-safe-area-context') as {
320
+ SafeAreaInsetsContext?: React.Context<Insets | null>;
321
+ initialWindowMetrics?: { insets: Insets } | null;
322
+ };
323
+ } catch {
324
+ return null;
325
+ }
326
+ })();
327
+ const InsetsContext = safeAreaLib?.SafeAreaInsetsContext ?? createContext<Insets | null>(null);
328
+
329
+ /** The screen's safe-area insets when `on` (from SafeAreaProvider, else the launch metrics), else 0. */
330
+ function useSafeInsets(on: boolean): Insets {
331
+ const fromProvider = useContext(InsetsContext);
332
+ if (!on) return NO_INSETS;
333
+ return fromProvider ?? safeAreaLib?.initialWindowMetrics?.insets ?? NO_INSETS;
270
334
  }
271
335
 
272
336
  const PEEK = 12;
@@ -289,12 +353,15 @@ export function Toaster({
289
353
  visibleToasts = 3,
290
354
  expand = false,
291
355
  offset,
356
+ safeArea = false,
357
+ bottomInset = 0,
292
358
  gap = 10,
293
359
  closeButton = false,
294
360
  haptics = true,
295
361
  style,
296
362
  }: ToasterProps) {
297
363
  const store = useToastStore();
364
+ const insets = useSafeInsets(safeArea);
298
365
  const all = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
299
366
  // Inside ToastProvider, show the whole scoped stack (id filter is for the global store only).
300
367
  const mine = store === globalStore ? all.filter((t) => t.toaster === id) : all;
@@ -317,7 +384,7 @@ export function Toaster({
317
384
  store={store}
318
385
  toasts={mine}
319
386
  duration={duration}
320
- offset={offset ?? 10}
387
+ offset={(offset ?? 10) + insets.top}
321
388
  haptics={haptics}
322
389
  reduceMotion={reduceMotion}
323
390
  style={style}
@@ -325,7 +392,8 @@ export function Toaster({
325
392
  );
326
393
  }
327
394
 
328
- const frontH = heights[active[0]?.id] ?? GUESS_HEIGHT;
395
+ const frontId = active[0]?.id;
396
+ const frontH = (frontId === undefined ? undefined : heights[frontId]) ?? GUESS_HEIGHT;
329
397
  const layout: Record<string, StackLayout> = {};
330
398
  let acc = 0;
331
399
  active.forEach((t, i) => {
@@ -347,14 +415,14 @@ export function Toaster({
347
415
  });
348
416
 
349
417
  return (
350
- <View pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
418
+ <GestureHandlerRootView pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
351
419
  {[...mine].reverse().map((t) => (
352
420
  <ToastItem
353
421
  key={t.id}
354
422
  data={t}
355
423
  layout={layout[t.id]}
356
424
  edge={position}
357
- edgeOffset={offset ?? 16}
425
+ edgeOffset={(offset ?? 16) + (position === 'bottom' ? insets.bottom + bottomInset : insets.top)}
358
426
  variant={variant}
359
427
  duration={t.duration ?? (t.type === 'loading' ? Infinity : duration)}
360
428
  paused={expanded}
@@ -369,7 +437,7 @@ export function Toaster({
369
437
  }}
370
438
  />
371
439
  ))}
372
- </View>
440
+ </GestureHandlerRootView>
373
441
  );
374
442
  }
375
443
 
@@ -379,14 +447,19 @@ interface StackLayout {
379
447
  y: number;
380
448
  scale: number;
381
449
  hidden: boolean;
382
- clamp?: number;
450
+ clamp?: number | undefined;
451
+
383
452
  faded: boolean;
384
453
  }
385
454
 
455
+ const sameLayout = (a: StackLayout, b: StackLayout) =>
456
+ a.y === b.y && a.scale === b.scale && a.hidden === b.hidden && a.clamp === b.clamp && a.faded === b.faded;
457
+
386
458
  interface ToastItemProps {
387
459
  data: ToastData;
388
460
  /** Undefined while the toast is leaving. */
389
- layout?: StackLayout;
461
+ layout?: StackLayout | undefined;
462
+
390
463
  edge: ToasterPosition;
391
464
  edgeOffset: number;
392
465
  variant: Exclude<ToasterVariant, 'island'>;
@@ -401,6 +474,7 @@ interface ToastItemProps {
401
474
  }
402
475
 
403
476
  function rubber(d: number) {
477
+ 'worklet';
404
478
  return (d * 40) / (40 + d);
405
479
  }
406
480
 
@@ -420,117 +494,113 @@ function ToastItem({
420
494
  onRemoved,
421
495
  }: ToastItemProps) {
422
496
  const toast = useToast();
423
- const { colors, radius, dark, font } = useKit();
497
+ const { colors, radius, dark, font, labels } = useKit();
424
498
  const ff = font.family ? { fontFamily: font.family } : null;
425
499
  const toward = edge === 'bottom' ? 1 : -1; // swipe direction that dismisses toward the edge
426
- const last = useRef(layout ?? { y: 0, scale: 1, hidden: false, faded: false });
427
- if (layout) last.current = layout;
428
- const L = last.current;
429
-
430
- // Every value here is native-driven: they all end up in transform / opacity.
431
- const enter = useRef(new Animated.Value(0)).current;
432
- const y = useRef(new Animated.Value(L.y)).current;
433
- const scale = useRef(new Animated.Value(L.scale)).current;
434
- const vis = useRef(new Animated.Value(L.hidden ? 0 : 1)).current;
435
- const content = useRef(new Animated.Value(L.faded ? 0 : 1)).current;
436
- const dragX = useRef(new Animated.Value(0)).current;
437
- const dragY = useRef(new Animated.Value(0)).current;
438
- const [touching, setTouching] = useState(false);
439
- const gone = useRef(false);
500
+ // The last layout the stack gave this toast: it keeps its place while it leaves (layout turns undefined).
501
+ const [lastLayout, setLastLayout] = useState<StackLayout>(layout ?? { y: 0, scale: 1, hidden: false, faded: false });
502
+ if (layout && !sameLayout(layout, lastLayout)) setLastLayout(layout);
503
+ const L = layout ?? lastLayout;
504
+
505
+ // Every value here lives on the UI thread and ends up in transform / opacity.
506
+ const enter = useSharedValue(0);
507
+ const y = useSharedValue(L.y);
508
+ const scale = useSharedValue(L.scale);
509
+ const vis = useSharedValue(L.hidden ? 0 : 1);
510
+ const content = useSharedValue(L.faded ? 0 : 1);
511
+ const dragX = useSharedValue(0);
512
+ const dragY = useSharedValue(0);
513
+ const axis = useSharedValue<'x' | 'y' | null>(null);
514
+ const gone = useSharedValue(false);
515
+ const [pressing, setPressing] = useState(false);
516
+ const [dragging, setDragging] = useState(false);
440
517
 
441
518
  useToneHaptic(data.id, data.type, haptics && !data.dismissed);
442
519
 
443
520
  useEffect(() => {
444
- Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
521
+ enter.set(withSpring(1, SNAPPY));
445
522
  }, [enter]);
446
523
 
447
524
  const target = layout ?? L;
448
525
  useEffect(() => {
449
526
  if (!layout || data.dismissed) return;
450
- Animated.parallel([
451
- Animated.spring(y, { toValue: target.y, useNativeDriver: true, ...motion.snappy }),
452
- Animated.spring(scale, { toValue: target.scale, useNativeDriver: true, ...motion.snappy }),
453
- Animated.timing(vis, { toValue: target.hidden ? 0 : 1, duration: 200, useNativeDriver: true }),
454
- Animated.timing(content, { toValue: target.faded ? 0 : 1, duration: 200, useNativeDriver: true }),
455
- ]).start();
527
+ y.set(withSpring(target.y, SNAPPY));
528
+ scale.set(withSpring(target.scale, SNAPPY));
529
+ vis.set(withTiming(target.hidden ? 0 : 1, { duration: 200, reduceMotion: KEEP }));
530
+ content.set(withTiming(target.faded ? 0 : 1, { duration: 200, reduceMotion: KEEP }));
456
531
  // eslint-disable-next-line react-hooks/exhaustive-deps -- re-run on the numbers, not the object identity
457
532
  }, [target.y, target.scale, target.hidden, target.faded, data.dismissed]);
458
533
 
459
534
  // Leave: fade + slide toward the edge, then drop out of the store.
460
535
  useEffect(() => {
461
- if (!data.dismissed || gone.current) return;
462
- gone.current = true;
463
- Animated.timing(enter, { toValue: 0, duration: 200, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(onRemoved);
536
+ if (!data.dismissed || gone.get()) return;
537
+ gone.set(true);
538
+ enter.set(
539
+ withTiming(0, { duration: 200, easing: Easing.in(Easing.quad), reduceMotion: KEEP }, () => {
540
+ scheduleOnRN(onRemoved);
541
+ }),
542
+ );
464
543
  // eslint-disable-next-line react-hooks/exhaustive-deps
465
544
  }, [data.dismissed]);
466
545
 
467
546
  // Auto-dismiss timer + drain line; both pause while the stack is open or a finger is down.
468
- const life = useLifetime(data.updatedAt, duration, !data.dismissed && !paused && !touching, () => toast.dismiss(data.id));
469
-
470
- const flyOut = (axis: 'x' | 'y', to: number) => {
471
- gone.current = true;
472
- Animated.parallel([
473
- Animated.timing(axis === 'x' ? dragX : dragY, {
474
- toValue: to,
475
- duration: 180,
476
- easing: Easing.out(Easing.quad),
477
- useNativeDriver: true,
547
+ const life = useLifetime(data.updatedAt, duration, !data.dismissed && !paused && !pressing && !dragging, () => toast.dismiss(data.id));
548
+
549
+ const flyOut = (along: 'x' | 'y', to: number) => {
550
+ 'worklet';
551
+ gone.set(true);
552
+ (along === 'x' ? dragX : dragY).set(withTiming(to, { duration: 180, easing: Easing.out(Easing.quad), reduceMotion: KEEP }));
553
+ enter.set(
554
+ withTiming(0, { duration: 180, reduceMotion: KEEP }, () => {
555
+ scheduleOnRN(onRemoved);
478
556
  }),
479
- Animated.timing(enter, { toValue: 0, duration: 180, useNativeDriver: true }),
480
- ]).start(onRemoved);
557
+ );
481
558
  };
482
559
 
483
- const pan = useRef(
484
- (() => {
485
- const gesture = { axis: null as 'x' | 'y' | null };
486
- return PanResponder.create({
487
- onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 6 || Math.abs(g.dy) > 6,
488
- onPanResponderGrant: () => {
489
- gesture.axis = null;
490
- setTouching(true);
491
- },
492
- onPanResponderMove: (_, g) => {
493
- if (!gesture.axis) gesture.axis = Math.abs(g.dx) > Math.abs(g.dy) ? 'x' : 'y';
494
- if (gesture.axis === 'x') dragX.setValue(g.dx);
495
- else {
496
- const d = g.dy * toward;
497
- dragY.setValue(d >= 0 ? g.dy : -toward * rubber(-d));
498
- }
499
- },
500
- onPanResponderRelease: (_, g) => {
501
- setTouching(false);
502
- const { axis } = gesture;
503
- if (axis === 'x' && (Math.abs(g.dx) > 90 || Math.abs(g.vx) > 0.7)) return flyOut('x', Math.sign(g.dx || g.vx) * 520);
504
- if (axis === 'y' && (g.dy * toward > 36 || g.vy * toward > 0.6)) return flyOut('y', toward * 140);
505
- Animated.parallel([
506
- Animated.spring(dragX, { toValue: 0, useNativeDriver: true, ...motion.snappy }),
507
- Animated.spring(dragY, { toValue: 0, useNativeDriver: true, ...motion.snappy }),
508
- ]).start();
509
- },
510
- onPanResponderTerminate: () => {
511
- setTouching(false);
512
- Animated.spring(dragX, { toValue: 0, ...motion.snappy, useNativeDriver: true }).start();
513
- Animated.spring(dragY, { toValue: 0, ...motion.snappy, useNativeDriver: true }).start();
514
- },
515
- });
516
- })(),
517
- ).current;
560
+ // Swipe sideways, or toward the edge, to dismiss. Runs on the UI thread.
561
+ const pan = usePanGesture({
562
+ minDistance: 6,
563
+ onActivate: () => {
564
+ axis.set(null);
565
+ scheduleOnRN(setDragging, true);
566
+ },
567
+ onUpdate: (e) => {
568
+ if (!axis.get()) axis.set(Math.abs(e.translationX) > Math.abs(e.translationY) ? 'x' : 'y');
569
+ if (axis.get() === 'x') dragX.set(e.translationX);
570
+ else {
571
+ const d = e.translationY * toward;
572
+ dragY.set(d >= 0 ? e.translationY : -toward * rubber(-d));
573
+ }
574
+ },
575
+ onDeactivate: (e) => {
576
+ // Velocities arrive in points per second.
577
+ const { translationX: dx, translationY: dy, velocityX: vx, velocityY: vy } = e;
578
+ if (!e.canceled && axis.get() === 'x' && (Math.abs(dx) > 90 || Math.abs(vx) > 700)) return flyOut('x', Math.sign(dx || vx) * 520);
579
+ if (!e.canceled && axis.get() === 'y' && (dy * toward > 36 || vy * toward > 600)) return flyOut('y', toward * 140);
580
+ dragX.set(withSpring(0, SNAPPY));
581
+ dragY.set(withSpring(0, SNAPPY));
582
+ },
583
+ onFinalize: () => {
584
+ scheduleOnRN(setDragging, false);
585
+ },
586
+ });
587
+
588
+ const laneStyle = useAnimatedStyle(() => ({
589
+ opacity: enter.get() * vis.get() * interpolate(dragX.get(), [-260, 0, 260], [0, 1, 0], Extrapolation.CLAMP),
590
+ transform: [
591
+ { translateY: y.get() + dragY.get() + toward * 28 * (1 - enter.get()) },
592
+ { translateX: dragX.get() },
593
+ { scale: scale.get() * (0.96 + 0.04 * enter.get()) },
594
+ ],
595
+ }));
596
+ const contentStyle = useAnimatedStyle(() => ({ opacity: content.get() }));
597
+ const barStyle = useAnimatedStyle(() => ({ opacity: content.get(), transform: [{ scaleX: life.get() }] }));
518
598
 
519
599
  const look = theme(variant, data.type, colors, dark);
520
600
  const minimal = variant === 'minimal';
521
601
  // A one-line pill stays a capsule; with a description it becomes a soft rounded card.
522
602
  const r = minimal ? (data.description ? radius.scale(20) : radius.capsule(44)) : radius.lg;
523
603
 
524
- const opacity = Animated.multiply(
525
- Animated.multiply(enter, vis),
526
- dragX.interpolate({ inputRange: [-260, 0, 260], outputRange: [0, 1, 0], extrapolate: 'clamp' }),
527
- );
528
- const translateY = Animated.add(
529
- Animated.add(y, dragY),
530
- enter.interpolate({ inputRange: [0, 1], outputRange: [toward * 28, 0] }),
531
- );
532
- const s = Animated.multiply(scale, enter.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }));
533
-
534
604
  const typed = data.type !== 'default';
535
605
  const icon =
536
606
  data.icon === false ? null : (data.icon ??
@@ -544,88 +614,83 @@ function ToastItem({
544
614
  style={[
545
615
  styles.lane,
546
616
  edge === 'bottom' ? { bottom: edgeOffset } : { top: edgeOffset },
547
- {
548
- opacity,
549
- transform: [{ translateY }, { translateX: dragX }, { scale: s }],
550
- transformOrigin: edge === 'bottom' ? 'center top' : 'center bottom',
551
- },
617
+ { transformOrigin: edge === 'bottom' ? 'center top' : 'center bottom' },
618
+ laneStyle,
552
619
  ]}
553
620
  >
554
621
  {/* Shadow layer (unclipped) */}
555
- <View
556
- {...pan.panHandlers}
557
- style={[
558
- minimal ? styles.cardMinimal : styles.cardFull,
559
- { borderRadius: r, backgroundColor: look.bg, maxHeight: L.clamp, boxShadow: shadowFor(dark, minimal) },
560
- ]}
561
- >
562
- {/* Surface (clips the glow and drain line to the corners) */}
563
- <View style={[styles.clip, { borderRadius: r, borderColor: look.border, maxHeight: L.clamp }]}>
564
- <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: content }]}>
565
- {showGlow ? <Glow color={look.glowColor} strength={look.glow} x={32} y={32} /> : null}
566
- </Animated.View>
567
- <Pressable
568
- onPress={onPress}
569
- onPressIn={() => setTouching(true)}
570
- onPressOut={() => setTouching(false)}
571
- accessibilityRole="alert"
572
- accessibilityLiveRegion="polite"
573
- accessibilityLabel={[data.title, data.description].filter(Boolean).join('. ')}
574
- onLayout={(e) => onHeight(Math.round(e.nativeEvent.layout.height))}
575
- >
576
- <Animated.View style={[styles.row, minimal ? styles.padMinimal : icon ? styles.padIcon : styles.pad, { opacity: content }]}>
577
- {icon ? <View style={!minimal && data.description ? styles.iconTop : null}>{icon}</View> : null}
578
- <View style={minimal ? styles.textMinimal : styles.text}>
579
- <Text numberOfLines={minimal ? 1 : 3} style={[styles.title, ff, { color: look.title }]}>
580
- {data.title}
581
- </Text>
582
- {data.description ? (
583
- <Text numberOfLines={minimal ? 2 : 4} style={[styles.desc, minimal && styles.descMinimal, ff, { color: look.desc }]}>
584
- {data.description}
622
+ <GestureDetector gesture={pan}>
623
+ <View
624
+ style={[
625
+ minimal ? styles.cardMinimal : styles.cardFull,
626
+ { borderRadius: r, backgroundColor: look.bg, maxHeight: L.clamp, boxShadow: shadowFor(dark, minimal) },
627
+ ]}
628
+ >
629
+ {/* Surface (clips the glow and drain line to the corners) */}
630
+ <View style={[styles.clip, { borderRadius: r, borderColor: look.border, maxHeight: L.clamp }]}>
631
+ <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, contentStyle]}>
632
+ {showGlow ? <Glow color={look.glowColor} strength={look.glow} x={32} y={32} /> : null}
633
+ </Animated.View>
634
+ <Pressable
635
+ onPress={onPress}
636
+ onPressIn={() => setPressing(true)}
637
+ onPressOut={() => setPressing(false)}
638
+ accessibilityRole="alert"
639
+ accessibilityLiveRegion="polite"
640
+ accessibilityLabel={[data.title, data.description].filter(Boolean).join('. ')}
641
+ onLayout={(e) => onHeight(Math.round(e.nativeEvent.layout.height))}
642
+ >
643
+ <Animated.View style={[styles.row, minimal ? styles.padMinimal : icon ? styles.padIcon : styles.pad, contentStyle]}>
644
+ {icon ? <View style={!minimal && data.description ? styles.iconTop : null}>{icon}</View> : null}
645
+ <View style={minimal ? styles.textMinimal : styles.text}>
646
+ <Text numberOfLines={minimal ? 1 : 3} style={[styles.title, ff, { color: look.title }]}>
647
+ {data.title}
585
648
  </Text>
649
+ {data.description ? (
650
+ <Text numberOfLines={minimal ? 2 : 4} style={[styles.desc, minimal && styles.descMinimal, ff, { color: look.desc }]}>
651
+ {data.description}
652
+ </Text>
653
+ ) : null}
654
+ </View>
655
+ {data.action ? (
656
+ <Pressable
657
+ accessibilityRole="button"
658
+ hitSlop={8}
659
+ onPress={() => {
660
+ data.action?.onPress();
661
+ toast.dismiss(data.id);
662
+ }}
663
+ style={({ pressed }) => [
664
+ styles.action,
665
+ { borderRadius: radius.capsule(32), backgroundColor: look.actionBg, opacity: pressed ? 0.7 : 1 },
666
+ ]}
667
+ >
668
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.actionText, ff, { color: look.actionFg }]}>
669
+ {data.action.label}
670
+ </Text>
671
+ </Pressable>
586
672
  ) : null}
587
- </View>
588
- {data.action ? (
589
- <Pressable
590
- accessibilityRole="button"
591
- hitSlop={8}
592
- onPress={() => {
593
- data.action?.onPress();
594
- toast.dismiss(data.id);
595
- }}
596
- style={({ pressed }) => [
597
- styles.action,
598
- { borderRadius: radius.capsule(32), backgroundColor: look.actionBg, opacity: pressed ? 0.7 : 1 },
599
- ]}
600
- >
601
- <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.actionText, ff, { color: look.actionFg }]}>
602
- {data.action.label}
603
- </Text>
604
- </Pressable>
605
- ) : null}
606
- {closeButton ? (
607
- <Pressable
608
- accessibilityRole="button"
609
- accessibilityLabel="Dismiss"
610
- hitSlop={12}
611
- onPress={() => toast.dismiss(data.id)}
612
- style={styles.close}
613
- >
614
- <Svg width={14} height={14} viewBox="0 0 24 24">
615
- <Path d="M6 6l12 12M18 6L6 18" stroke={look.desc} strokeWidth={2.4} strokeLinecap="round" />
616
- </Svg>
617
- </Pressable>
618
- ) : null}
619
- </Animated.View>
620
- </Pressable>
621
- {showBar ? (
622
- <Animated.View
623
- pointerEvents="none"
624
- style={[styles.bar, { backgroundColor: look.bar, opacity: content, transform: [{ scaleX: life }] }]}
625
- />
626
- ) : null}
673
+ {closeButton ? (
674
+ <Pressable
675
+ accessibilityRole="button"
676
+ accessibilityLabel={labels.dismiss}
677
+ hitSlop={12}
678
+ onPress={() => toast.dismiss(data.id)}
679
+ style={styles.close}
680
+ >
681
+ <Svg width={14} height={14} viewBox="0 0 24 24">
682
+ <Path d="M6 6l12 12M18 6L6 18" stroke={look.desc} strokeWidth={2.4} strokeLinecap="round" />
683
+ </Svg>
684
+ </Pressable>
685
+ ) : null}
686
+ </Animated.View>
687
+ </Pressable>
688
+ {showBar ? (
689
+ <Animated.View pointerEvents="none" style={[styles.bar, { backgroundColor: look.bar }, barStyle]} />
690
+ ) : null}
691
+ </View>
627
692
  </View>
628
- </View>
693
+ </GestureDetector>
629
694
  </Animated.View>
630
695
  );
631
696
  }
@@ -643,18 +708,19 @@ interface IslandToasterProps {
643
708
  offset: number;
644
709
  haptics: boolean;
645
710
  reduceMotion: boolean;
646
- style?: StyleProp<ViewStyle>;
711
+ style?: StyleProp<ViewStyle> | undefined;
712
+
647
713
  }
648
714
 
649
715
  /**
650
716
  * Dynamic-Island host: one black capsule at the top centre. It widens, then grows taller into a card,
651
717
  * holds, and folds back into the capsule. A newer toast squashes the island and swaps its content in place.
652
718
  *
653
- * Drivers: `w` / `h` (size, corner) are JS-driven on `frame` + `clip`; presence, squash, drag and content
654
- * fade are native-driven on their own wrapper views.
719
+ * Everything animates on the UI thread: `w` / `h` (size, corner) on `frame` + `clip`; presence, squash,
720
+ * drag and content fade on their own wrapper views.
655
721
  */
656
722
  function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion, style }: IslandToasterProps) {
657
- const { colors, radius, dark, font } = useKit();
723
+ const { colors, radius, dark, font, labels } = useKit();
658
724
  const ff = font.family ? { fontFamily: font.family } : null;
659
725
  const active = toasts.filter((t) => !t.dismissed);
660
726
  const front = active[0];
@@ -663,27 +729,25 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
663
729
  const stageRef = useRef<Stage>('hidden');
664
730
  const [shownId, setShownIdState] = useState<string | null>(null);
665
731
  const shownRef = useRef<string | null>(null);
666
- const lastShown = useRef<ToastData | null>(null);
732
+ const [lastShown, setLastShown] = useState<ToastData | null>(null);
667
733
  const [laneW, setLaneW] = useState(0);
668
734
  const [contentH, setContentH] = useState(0);
669
735
  const [expanded, setExpanded] = useState(false);
670
- const [touching, setTouching] = useState(false);
736
+ const [pressing, setPressing] = useState(false);
737
+ const [dragging, setDragging] = useState(false);
671
738
 
672
739
  const live = shownId ? toasts.find((t) => t.id === shownId) : undefined;
673
- useEffect(() => {
674
- if (live) lastShown.current = live;
675
- });
676
- const shown = live ?? (shownId ? lastShown.current : null);
677
-
678
- // JS-driven (layout)
679
- const w = useRef(new Animated.Value(CAP_W)).current;
680
- const h = useRef(new Animated.Value(CAP_H)).current;
681
- // Native-driven (transform / opacity)
682
- const presence = useRef(new Animated.Value(0)).current;
683
- const sqX = useRef(new Animated.Value(1)).current;
684
- const sqY = useRef(new Animated.Value(1)).current;
685
- const dragY = useRef(new Animated.Value(0)).current;
686
- const content = useRef(new Animated.Value(0)).current;
740
+ // Keep showing the last version of a toast after the store drops it, while the island closes.
741
+ if (live && live !== lastShown) setLastShown(live);
742
+ const shown = live ?? (shownId ? lastShown : null);
743
+
744
+ const w = useSharedValue(CAP_W);
745
+ const h = useSharedValue(CAP_H);
746
+ const presence = useSharedValue(0);
747
+ const sqX = useSharedValue(1);
748
+ const sqY = useSharedValue(1);
749
+ const dragY = useSharedValue(0);
750
+ const content = useSharedValue(0);
687
751
 
688
752
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
689
753
  const later = (ms: number, fn: () => void) => {
@@ -706,16 +770,17 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
706
770
 
707
771
  const squash = () => {
708
772
  if (reduceMotion) return;
709
- Animated.sequence([
710
- Animated.parallel([
711
- Animated.timing(sqX, { toValue: 1.05, duration: 90, easing: Easing.out(Easing.quad), useNativeDriver: true }),
712
- Animated.timing(sqY, { toValue: 0.9, duration: 90, easing: Easing.out(Easing.quad), useNativeDriver: true }),
713
- ]),
714
- Animated.parallel([
715
- Animated.spring(sqX, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
716
- Animated.spring(sqY, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
717
- ]),
718
- ]).start();
773
+ const press = { duration: 90, easing: Easing.out(Easing.quad), reduceMotion: KEEP };
774
+ sqX.set(withSequence(withTiming(1.05, press), withSpring(1, BOUNCY)));
775
+ sqY.set(withSequence(withTiming(0.9, press), withSpring(1, BOUNCY)));
776
+ };
777
+
778
+ // Swap the island's content to another toast once the old content has faded out.
779
+ const swapTo = (id: string) => {
780
+ setShown(id);
781
+ setExpanded(false);
782
+ squash();
783
+ content.set(withDelay(30, withTiming(1, { duration: 200, reduceMotion: KEEP })));
719
784
  };
720
785
 
721
786
  // Lifecycle: react to the front toast changing.
@@ -724,7 +789,7 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
724
789
  if (front) {
725
790
  if (!current || stageRef.current === 'hidden') {
726
791
  clearLater();
727
- dragY.setValue(0);
792
+ dragY.set(0);
728
793
  setShown(front.id);
729
794
  setExpanded(false);
730
795
  setStage('capsule');
@@ -734,12 +799,12 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
734
799
  // Replace in place: older toasts are superseded.
735
800
  toasts.forEach((t) => t.id !== front.id && store.remove(t.id));
736
801
  if (stageRef.current === 'open') {
737
- Animated.timing(content, { toValue: 0, duration: 90, useNativeDriver: true }).start(() => {
738
- setShown(front.id);
739
- setExpanded(false);
740
- squash();
741
- Animated.timing(content, { toValue: 1, duration: 200, delay: 30, useNativeDriver: true }).start();
742
- });
802
+ const next = front.id;
803
+ content.set(
804
+ withTiming(0, { duration: 90, reduceMotion: KEEP }, () => {
805
+ scheduleOnRN(swapTo, next);
806
+ }),
807
+ );
743
808
  } else {
744
809
  setShown(front.id);
745
810
  setExpanded(false);
@@ -772,21 +837,23 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
772
837
  // eslint-disable-next-line react-hooks/exhaustive-deps
773
838
  }, [shownType]);
774
839
 
775
- // Declarative size / presence per stage.
840
+ // Declarative size / presence per stage: opening widens first, closing shortens first.
776
841
  const openW = laneW ? Math.min(laneW - 24, 380) : CAP_W;
777
842
  useEffect(() => {
778
843
  const isOpen = stage === 'open';
779
844
  const tw = isOpen ? openW : CAP_W;
780
845
  const th = isOpen ? Math.max(CAP_H, contentH || 64) : CAP_H;
781
- const wA = Animated.spring(w, { toValue: tw, useNativeDriver: false, ...(isOpen ? motion.bouncy : motion.snappy) });
782
- const hA = Animated.spring(h, { toValue: th, useNativeDriver: false, ...motion.snappy });
783
- Animated.spring(presence, { toValue: stage === 'hidden' ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
846
+ const wA = withSpring(tw, isOpen ? BOUNCY : SNAPPY);
847
+ const hA = withSpring(th, SNAPPY);
848
+ presence.set(withSpring(stage === 'hidden' ? 0 : 1, SNAPPY));
784
849
  if (isOpen) {
785
- Animated.stagger(reduceMotion ? 0 : 90, [wA, hA]).start();
786
- Animated.timing(content, { toValue: 1, duration: 220, delay: reduceMotion ? 0 : 160, useNativeDriver: true }).start();
850
+ w.set(wA);
851
+ h.set(withDelay(reduceMotion ? 0 : 90, hA));
852
+ content.set(withDelay(reduceMotion ? 0 : 160, withTiming(1, { duration: 220, reduceMotion: KEEP })));
787
853
  } else {
788
- Animated.timing(content, { toValue: 0, duration: 110, useNativeDriver: true }).start();
789
- Animated.stagger(reduceMotion ? 0 : 70, [hA, wA]).start();
854
+ content.set(withTiming(0, { duration: 110, reduceMotion: KEEP }));
855
+ h.set(hA);
856
+ w.set(withDelay(reduceMotion ? 0 : 70, wA));
790
857
  }
791
858
  }, [stage, openW, contentH, reduceMotion, w, h, presence, content]);
792
859
 
@@ -796,53 +863,75 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
796
863
  const life = useLifetime(
797
864
  shown ? shown.updatedAt : undefined,
798
865
  dur,
799
- !!shown && !shown.dismissed && stage === 'open' && !touching && !expanded,
866
+ !!shown && !shown.dismissed && stage === 'open' && !pressing && !dragging && !expanded,
800
867
  () => shownRef.current && store.toast.dismiss(shownRef.current),
801
868
  );
802
869
 
803
- // Swipe up to dismiss.
804
- const pan = useRef(
805
- PanResponder.create({
806
- onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 6 && Math.abs(g.dy) > Math.abs(g.dx),
807
- onPanResponderGrant: () => setTouching(true),
808
- onPanResponderMove: (_, g) => dragY.setValue(g.dy < 0 ? g.dy : rubber(g.dy)),
809
- onPanResponderRelease: (_, g) => {
810
- setTouching(false);
811
- const id = shownRef.current;
812
- if (id && (g.dy < -28 || g.vy < -0.5)) {
813
- clearLater();
814
- Animated.parallel([
815
- Animated.timing(dragY, { toValue: -90, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: true }),
816
- Animated.timing(presence, { toValue: 0, duration: 160, useNativeDriver: true }),
817
- Animated.timing(content, { toValue: 0, duration: 100, useNativeDriver: true }),
818
- ]).start(() => {
819
- w.setValue(CAP_W);
820
- h.setValue(CAP_H);
821
- dragY.setValue(0);
822
- setExpanded(false);
823
- setStage('hidden');
824
- setShown(null);
825
- store.remove(id);
826
- });
827
- return;
828
- }
829
- Animated.spring(dragY, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
830
- },
831
- onPanResponderTerminate: () => {
832
- setTouching(false);
833
- Animated.spring(dragY, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
834
- },
835
- }),
836
- ).current;
870
+ // After a flick: reset to a closed capsule and drop the toast.
871
+ const flicked = () => {
872
+ const id = shownRef.current;
873
+ w.set(CAP_W);
874
+ h.set(CAP_H);
875
+ dragY.set(0);
876
+ setExpanded(false);
877
+ setStage('hidden');
878
+ setShown(null);
879
+ if (id) store.remove(id);
880
+ };
881
+
882
+ // Swipe up to dismiss. The drag and the flick run on the UI thread.
883
+ const pan = usePanGesture({
884
+ activeOffsetY: [-6, 6],
885
+ failOffsetX: [-6, 6],
886
+ onActivate: () => {
887
+ scheduleOnRN(setDragging, true);
888
+ },
889
+ onUpdate: (e) => {
890
+ dragY.set(e.translationY < 0 ? e.translationY : rubber(e.translationY));
891
+ },
892
+ onDeactivate: (e) => {
893
+ if (!e.canceled && (e.translationY < -28 || e.velocityY < -500)) {
894
+ scheduleOnRN(clearLater);
895
+ dragY.set(
896
+ withTiming(-90, { duration: 180, easing: Easing.out(Easing.quad), reduceMotion: KEEP }, () => {
897
+ scheduleOnRN(flicked);
898
+ }),
899
+ );
900
+ presence.set(withTiming(0, { duration: 160, reduceMotion: KEEP }));
901
+ content.set(withTiming(0, { duration: 100, reduceMotion: KEEP }));
902
+ return;
903
+ }
904
+ dragY.set(withSpring(0, SNAPPY));
905
+ },
906
+ onFinalize: () => {
907
+ scheduleOnRN(setDragging, false);
908
+ },
909
+ });
837
910
 
838
911
  const bg = dark ? mix('#000000', colors.foreground, 0.05) : '#000000';
839
912
  const fg = readableOn(bg);
840
913
  const tone = !shown || shown.type === 'default' || shown.type === 'loading' ? mix(fg, bg, 0.25) : toneOf(shown.type, colors, true);
841
914
  const capR = radius.capsule(CAP_H);
842
915
  const cardR = Math.max(capR, radius.scale(30));
843
- const corner = h.interpolate({ inputRange: [CAP_H, CAP_H + 28], outputRange: [capR, cardR], extrapolate: 'clamp' });
844
- const left = Animated.multiply(Animated.subtract(w, openW), 0.5);
845
- const pScale = presence.interpolate({ inputRange: [0, 1], outputRange: [0.5, 1] });
916
+
917
+ const outerStyle = useAnimatedStyle(() => {
918
+ const p = 0.5 + 0.5 * presence.get();
919
+ return {
920
+ opacity: presence.get(),
921
+ transform: [{ translateY: dragY.get() }, { scaleX: p * sqX.get() }, { scaleY: p * sqY.get() }],
922
+ };
923
+ });
924
+ const cornerStyle = useAnimatedStyle(() => ({
925
+ borderRadius: interpolate(h.get(), [CAP_H, CAP_H + 28], [capR, cardR], Extrapolation.CLAMP),
926
+ }));
927
+ const frameStyle = useAnimatedStyle(() => ({ width: w.get(), height: h.get() }));
928
+ const placeStyle = useAnimatedStyle(() => ({ left: (w.get() - openW) * 0.5 }));
929
+ const contentStyle = useAnimatedStyle(() => ({
930
+ opacity: content.get(),
931
+ transform: [{ scale: 0.92 + 0.08 * content.get() }],
932
+ }));
933
+ const fillStyle = useAnimatedStyle(() => ({ transform: [{ scaleX: life.get() }] }));
934
+
846
935
  const typed = !!shown && shown.type !== 'default';
847
936
  const icon =
848
937
  !shown || shown.icon === false
@@ -851,106 +940,94 @@ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion,
851
940
  const showBar = Number.isFinite(dur);
852
941
 
853
942
  return (
854
- <View pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
943
+ <GestureHandlerRootView pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
855
944
  <View
856
945
  pointerEvents="box-none"
857
946
  style={[styles.islandLane, { top: offset }]}
858
947
  onLayout={(e) => setLaneW(Math.round(e.nativeEvent.layout.width))}
859
948
  >
860
949
  {shown ? (
861
- <Animated.View
862
- {...pan.panHandlers}
863
- style={{
864
- opacity: presence,
865
- transform: [{ translateY: dragY }, { scaleX: Animated.multiply(pScale, sqX) }, { scaleY: Animated.multiply(pScale, sqY) }],
866
- transformOrigin: 'center top',
867
- }}
868
- >
869
- <Animated.View
870
- style={{
871
- width: w,
872
- height: h,
873
- borderRadius: corner,
874
- backgroundColor: bg,
875
- boxShadow: dark
876
- ? '0 0 0 1px rgba(255,255,255,0.08), 0 10px 30px -6px rgba(0,0,0,0.6)'
877
- : '0 2px 6px rgba(0,0,0,0.12), 0 14px 36px -8px rgba(0,0,0,0.35)',
878
- }}
879
- >
880
- <Animated.View style={[styles.islandClip, { borderRadius: corner }]}>
881
- <Animated.View style={[styles.islandPlace, { width: openW, left }]}>
882
- <Animated.View
883
- style={{
884
- opacity: content,
885
- transform: [{ scale: content.interpolate({ inputRange: [0, 1], outputRange: [0.92, 1] }) }],
886
- }}
887
- onLayout={(e) => setContentH(Math.round(e.nativeEvent.layout.height))}
888
- >
889
- {typed && shown.icon !== false ? <Glow color={tone} strength={0.3} x={33} y={32} /> : null}
890
- <Pressable
891
- onPress={() => setExpanded((x) => !x)}
892
- onPressIn={() => setTouching(true)}
893
- onPressOut={() => setTouching(false)}
894
- accessibilityRole="alert"
895
- accessibilityLiveRegion="polite"
896
- accessibilityLabel={[shown.title, shown.description].filter(Boolean).join('. ')}
897
- accessibilityHint={shown.description || shown.action ? (expanded ? 'Collapses the notification' : 'Expands the notification') : undefined}
898
- accessibilityState={{ expanded }}
899
- style={[styles.row, icon ? styles.islandPadIcon : styles.islandPad]}
900
- >
901
- {icon ? <View style={expanded && shown.description ? styles.iconTop : null}>{icon}</View> : null}
902
- <View style={styles.text}>
903
- <Text numberOfLines={expanded ? 3 : 1} style={[styles.title, ff, { color: fg }]}>
904
- {shown.title}
905
- </Text>
906
- {shown.description ? (
907
- <Text numberOfLines={expanded ? undefined : 1} style={[styles.desc, ff, { color: alpha(fg, 0.62) }]}>
908
- {shown.description}
950
+ <GestureDetector gesture={pan}>
951
+ <Animated.View style={[{ transformOrigin: 'center top' }, outerStyle]}>
952
+ <Animated.View
953
+ style={[
954
+ {
955
+ backgroundColor: bg,
956
+ boxShadow: dark
957
+ ? '0 0 0 1px rgba(255,255,255,0.08), 0 10px 30px -6px rgba(0,0,0,0.6)'
958
+ : '0 2px 6px rgba(0,0,0,0.12), 0 14px 36px -8px rgba(0,0,0,0.35)',
959
+ },
960
+ frameStyle,
961
+ cornerStyle,
962
+ ]}
963
+ >
964
+ <Animated.View style={[styles.islandClip, cornerStyle]}>
965
+ <Animated.View style={[styles.islandPlace, { width: openW }, placeStyle]}>
966
+ <Animated.View style={contentStyle} onLayout={(e) => setContentH(Math.round(e.nativeEvent.layout.height))}>
967
+ {typed && shown.icon !== false ? <Glow color={tone} strength={0.3} x={33} y={32} /> : null}
968
+ <Pressable
969
+ onPress={() => setExpanded((x) => !x)}
970
+ onPressIn={() => setPressing(true)}
971
+ onPressOut={() => setPressing(false)}
972
+ accessibilityRole="alert"
973
+ accessibilityLiveRegion="polite"
974
+ accessibilityLabel={[shown.title, shown.description].filter(Boolean).join('. ')}
975
+ accessibilityHint={shown.description || shown.action ? (expanded ? labels.collapseNotification : labels.expandNotification) : undefined}
976
+ accessibilityState={{ expanded }}
977
+ style={[styles.row, icon ? styles.islandPadIcon : styles.islandPad]}
978
+ >
979
+ {icon ? <View style={expanded && shown.description ? styles.iconTop : null}>{icon}</View> : null}
980
+ <View style={styles.text}>
981
+ <Text numberOfLines={expanded ? 3 : 1} style={[styles.title, ff, { color: fg }]}>
982
+ {shown.title}
909
983
  </Text>
984
+ {shown.description ? (
985
+ <Text numberOfLines={expanded ? undefined : 1} style={[styles.desc, ff, { color: alpha(fg, 0.62) }]}>
986
+ {shown.description}
987
+ </Text>
988
+ ) : null}
989
+ </View>
990
+ {!expanded && shown.action ? (
991
+ <View style={[styles.islandChip, { borderRadius: radius.capsule(24), backgroundColor: alpha(fg, 0.14) }]}>
992
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.islandChipText, ff, { color: fg }]}>
993
+ {shown.action.label}
994
+ </Text>
995
+ </View>
910
996
  ) : null}
911
- </View>
912
- {!expanded && shown.action ? (
913
- <View style={[styles.islandChip, { borderRadius: radius.capsule(24), backgroundColor: alpha(fg, 0.14) }]}>
914
- <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.islandChipText, ff, { color: fg }]}>
915
- {shown.action.label}
916
- </Text>
997
+ </Pressable>
998
+ {expanded && shown.action ? (
999
+ <View style={styles.islandActions}>
1000
+ <Pressable
1001
+ accessibilityRole="button"
1002
+ onPress={() => {
1003
+ shown.action?.onPress();
1004
+ store.toast.dismiss(shown.id);
1005
+ }}
1006
+ style={({ pressed }) => [
1007
+ styles.islandAction,
1008
+ { borderRadius: radius.capsule(44), backgroundColor: alpha(fg, pressed ? 0.24 : 0.16) },
1009
+ ]}
1010
+ >
1011
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.title, ff, { color: fg }]}>
1012
+ {shown.action.label}
1013
+ </Text>
1014
+ </Pressable>
917
1015
  </View>
918
1016
  ) : null}
919
- </Pressable>
920
- {expanded && shown.action ? (
921
- <View style={styles.islandActions}>
922
- <Pressable
923
- accessibilityRole="button"
924
- onPress={() => {
925
- shown.action?.onPress();
926
- store.toast.dismiss(shown.id);
927
- }}
928
- style={({ pressed }) => [
929
- styles.islandAction,
930
- { borderRadius: radius.capsule(44), backgroundColor: alpha(fg, pressed ? 0.24 : 0.16) },
931
- ]}
932
- >
933
- <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.title, ff, { color: fg }]}>
934
- {shown.action.label}
935
- </Text>
936
- </Pressable>
937
- </View>
938
- ) : null}
939
- {showBar ? (
940
- <View style={[styles.islandTrack, { backgroundColor: alpha(fg, 0.1) }]}>
941
- <Animated.View
942
- style={[styles.islandFill, { backgroundColor: typed ? tone : alpha(fg, 0.5), transform: [{ scaleX: life }] }]}
943
- />
944
- </View>
945
- ) : null}
1017
+ {showBar ? (
1018
+ <View style={[styles.islandTrack, { backgroundColor: alpha(fg, 0.1) }]}>
1019
+ <Animated.View style={[styles.islandFill, { backgroundColor: typed ? tone : alpha(fg, 0.5) }, fillStyle]} />
1020
+ </View>
1021
+ ) : null}
1022
+ </Animated.View>
946
1023
  </Animated.View>
947
1024
  </Animated.View>
948
1025
  </Animated.View>
949
1026
  </Animated.View>
950
- </Animated.View>
1027
+ </GestureDetector>
951
1028
  ) : null}
952
1029
  </View>
953
- </View>
1030
+ </GestureHandlerRootView>
954
1031
  );
955
1032
  }
956
1033
 
@@ -973,7 +1050,7 @@ function toHex(v: number[]) {
973
1050
  function mix(a: string, b: string, t: number) {
974
1051
  if (!a.startsWith('#') || !b.startsWith('#')) return a;
975
1052
  const [x, y] = [rgb(a), rgb(b)];
976
- return toHex(x.map((v, i) => v + (y[i] - v) * t));
1053
+ return toHex(x.map((v, i) => v + ((y[i] ?? v) - v) * t));
977
1054
  }
978
1055
 
979
1056
  function toneOf(type: ToastType, c: Colors, dark: boolean) {
@@ -1087,6 +1164,28 @@ const ARC = 0.28; // spinner arc as a fraction of the ring
1087
1164
 
1088
1165
  const TRIANGLE_D = 'M10.3 4.2a2 2 0 0 1 3.4 0l7.1 12.6a2 2 0 0 1-1.7 3H4.9a2 2 0 0 1-1.7-3z';
1089
1166
 
1167
+ type IconStroke = {
1168
+ stroke: string;
1169
+ strokeWidth: number;
1170
+ strokeLinecap: 'round';
1171
+ strokeLinejoin: 'round';
1172
+ fill: 'none';
1173
+ };
1174
+
1175
+ /** A glyph stroke that draws itself in as `draw` runs from `from` to `to`. */
1176
+ function DrawPath({ d, len, draw, from = 0, to = 1, stroke }: { d: string; len: number; draw: SharedValue<number>; from?: number; to?: number; stroke: IconStroke }) {
1177
+ const animatedProps = useAnimatedProps(() => ({
1178
+ strokeDashoffset: interpolate(draw.get(), [from, to], [len, 0], Extrapolation.CLAMP),
1179
+ }));
1180
+ return <AnimatedPath d={d} {...stroke} strokeDasharray={[len, len]} animatedProps={animatedProps} />;
1181
+ }
1182
+
1183
+ /** The dot of an ! or i, fading in near the end of the draw. */
1184
+ function DrawDot({ cy, color, draw }: { cy: number; color: string; draw: SharedValue<number> }) {
1185
+ const animatedProps = useAnimatedProps(() => ({ opacity: interpolate(draw.get(), [0, 0.6, 1], [0, 0, 1]) }));
1186
+ return <AnimatedCircle cx={12} cy={cy} r={1.25} fill={color} animatedProps={animatedProps} />;
1187
+ }
1188
+
1090
1189
  /**
1091
1190
  * Tone icon that draws itself: the outline strokes in, then the glyph draws and springs. A loading
1092
1191
  * spinner's arc keeps turning while it closes into the success / error circle, then the mark draws in.
@@ -1099,121 +1198,100 @@ function LivingIcon({
1099
1198
  }: {
1100
1199
  type: ToastType;
1101
1200
  color: string;
1102
- halo?: boolean;
1103
- reduceMotion?: boolean;
1201
+ halo?: boolean | undefined;
1202
+
1203
+ reduceMotion?: boolean | undefined;
1204
+
1104
1205
  }) {
1105
1206
  const { radius } = useKit();
1106
- // JS-driven SVG props
1107
- const outline = useRef(new Animated.Value(0)).current;
1108
- const draw = useRef(new Animated.Value(0)).current;
1109
- // Native-driven transforms / opacity (separate views)
1110
- const pop = useRef(new Animated.Value(0.5)).current;
1111
- const spin = useRef(new Animated.Value(0)).current;
1112
- const ripple = useRef(new Animated.Value(1)).current;
1113
- const loopRef = useRef<Animated.CompositeAnimation | null>(null);
1207
+ const outline = useSharedValue(0);
1208
+ const draw = useSharedValue(0);
1209
+ const pop = useSharedValue(0.5);
1210
+ const spin = useSharedValue(0);
1211
+ const spinning = useSharedValue(false);
1212
+ const ripple = useSharedValue(1);
1114
1213
  const prev = useRef<ToastType | null>(null);
1115
1214
 
1116
1215
  useEffect(() => {
1117
1216
  const from = prev.current;
1118
1217
  prev.current = type;
1119
1218
  const stopSpin = () => {
1120
- loopRef.current?.stop();
1121
- loopRef.current = null;
1219
+ 'worklet';
1220
+ cancelAnimation(spin);
1221
+ spinning.set(false);
1122
1222
  };
1123
1223
 
1124
1224
  if (type === 'loading') {
1125
- if (!loopRef.current) {
1126
- spin.setValue(0);
1127
- loopRef.current = Animated.loop(
1128
- Animated.timing(spin, { toValue: 1, duration: 800, easing: Easing.linear, useNativeDriver: true }),
1129
- );
1130
- loopRef.current.start();
1225
+ if (!spinning.get()) {
1226
+ spinning.set(true);
1227
+ spin.set(0);
1228
+ spin.set(withRepeat(withTiming(1, { duration: 800, easing: Easing.linear, reduceMotion: KEEP }), -1, false));
1131
1229
  }
1132
- draw.setValue(0);
1133
- if (reduceMotion) outline.setValue(ARC);
1134
- else Animated.timing(outline, { toValue: ARC, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
1230
+ draw.set(0);
1231
+ if (reduceMotion) outline.set(ARC);
1232
+ else outline.set(withTiming(ARC, { duration: 300, easing: Easing.out(Easing.cubic), reduceMotion: KEEP }));
1135
1233
  return;
1136
1234
  }
1137
1235
 
1138
1236
  if (reduceMotion) {
1139
1237
  stopSpin();
1140
- outline.setValue(1);
1141
- draw.setValue(1);
1142
- pop.setValue(1);
1238
+ outline.set(1);
1239
+ draw.set(1);
1240
+ pop.set(1);
1143
1241
  return;
1144
1242
  }
1145
1243
 
1146
1244
  const morph = from === 'loading';
1147
- if (!morph) outline.setValue(0);
1148
- draw.setValue(0);
1149
- pop.setValue(0.5);
1245
+ if (!morph) outline.set(0);
1246
+ draw.set(0);
1247
+ pop.set(0.5);
1150
1248
  // The arc keeps spinning while it closes; a full ring looks the same at any angle, so it stops invisibly.
1151
- Animated.timing(outline, {
1152
- toValue: 1,
1153
- duration: morph ? 380 : 420,
1154
- easing: morph ? Easing.inOut(Easing.cubic) : Easing.out(Easing.cubic),
1155
- useNativeDriver: false,
1156
- }).start(({ finished }) => {
1157
- if (!finished) return;
1158
- stopSpin();
1159
- ripple.setValue(0);
1160
- Animated.parallel([
1161
- Animated.timing(draw, { toValue: 1, duration: 280, easing: Easing.out(Easing.cubic), useNativeDriver: false }),
1162
- Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
1163
- Animated.timing(ripple, { toValue: 1, duration: 700, easing: Easing.out(Easing.quad), useNativeDriver: true }),
1164
- ]).start();
1165
- });
1166
- }, [type, reduceMotion, outline, draw, pop, spin, ripple]);
1167
-
1168
- useEffect(() => () => loopRef.current?.stop(), []);
1169
-
1170
- const stroke = { stroke: color, strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
1171
- const dash = (len: number, from = 0, to = 1) => ({
1172
- strokeDasharray: [len, len],
1173
- strokeDashoffset: draw.interpolate({ inputRange: [from, to], outputRange: [len, 0], extrapolate: 'clamp' }),
1174
- });
1175
- const dot = draw.interpolate({ inputRange: [0, 0.6, 1], outputRange: [0, 0, 1] });
1249
+ outline.set(
1250
+ withTiming(
1251
+ 1,
1252
+ { duration: morph ? 380 : 420, easing: morph ? Easing.inOut(Easing.cubic) : Easing.out(Easing.cubic), reduceMotion: KEEP },
1253
+ (finished) => {
1254
+ if (!finished) return;
1255
+ stopSpin();
1256
+ ripple.set(0);
1257
+ draw.set(withTiming(1, { duration: 280, easing: Easing.out(Easing.cubic), reduceMotion: KEEP }));
1258
+ pop.set(withSpring(1, BOUNCY));
1259
+ ripple.set(withTiming(1, { duration: 700, easing: Easing.out(Easing.quad), reduceMotion: KEEP }));
1260
+ },
1261
+ ),
1262
+ );
1263
+ }, [type, reduceMotion, outline, draw, pop, spin, spinning, ripple]);
1264
+
1265
+ useEffect(() => () => cancelAnimation(spin), [spin]);
1266
+
1267
+ const stroke: IconStroke = { stroke: color, strokeWidth: 2.2, strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none' };
1176
1268
  const triangle = type === 'warning';
1177
1269
  const per = triangle ? TRIANGLE : RING;
1178
1270
  const box = halo ? HALO_OUT : ICON;
1179
1271
  const inset = (box - ICON) / 2;
1180
1272
  const circle = (d: number) => ({ width: d, height: d, borderRadius: radius.full, left: (box - d) / 2, top: (box - d) / 2 });
1181
1273
 
1274
+ const outlineProps = useAnimatedProps(() => ({ strokeDashoffset: per * (1 - outline.get()) }));
1275
+ const rippleStyle = useAnimatedStyle(() => ({
1276
+ opacity: 0.9 * (1 - ripple.get()),
1277
+ transform: [{ scale: 0.8 + 0.75 * ripple.get() }],
1278
+ }));
1279
+ const spinStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${spin.get() * 360}deg` }] }));
1280
+ const popStyle = useAnimatedStyle(() => ({ transform: [{ scale: pop.get() }] }));
1281
+
1182
1282
  return (
1183
1283
  <View style={{ width: box, height: box }} importantForAccessibility="no-hide-descendants">
1184
1284
  {halo ? (
1185
1285
  <>
1186
1286
  <View style={[styles.abs, circle(HALO_OUT), { backgroundColor: alpha(color, 0.08) }]} />
1187
1287
  <View style={[styles.abs, circle(HALO), { backgroundColor: alpha(color, 0.14) }]} />
1188
- <Animated.View
1189
- style={[
1190
- styles.abs,
1191
- circle(HALO_OUT),
1192
- {
1193
- borderWidth: 1.5,
1194
- borderColor: alpha(color, 0.35),
1195
- opacity: ripple.interpolate({ inputRange: [0, 1], outputRange: [0.9, 0] }),
1196
- transform: [{ scale: ripple.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1.55] }) }],
1197
- },
1198
- ]}
1199
- />
1288
+ <Animated.View style={[styles.abs, circle(HALO_OUT), { borderWidth: 1.5, borderColor: alpha(color, 0.35) }, rippleStyle]} />
1200
1289
  </>
1201
1290
  ) : null}
1202
- <Animated.View
1203
- style={[
1204
- styles.abs,
1205
- { left: inset, top: inset },
1206
- { transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }] },
1207
- ]}
1208
- >
1291
+ <Animated.View style={[styles.abs, { left: inset, top: inset }, spinStyle]}>
1209
1292
  <Svg width={ICON} height={ICON} viewBox="0 0 24 24">
1210
1293
  {triangle ? (
1211
- <AnimatedPath
1212
- d={TRIANGLE_D}
1213
- {...stroke}
1214
- strokeDasharray={[per, per]}
1215
- strokeDashoffset={outline.interpolate({ inputRange: [0, 1], outputRange: [per, 0] })}
1216
- />
1294
+ <AnimatedPath d={TRIANGLE_D} {...stroke} strokeDasharray={[per, per]} animatedProps={outlineProps} />
1217
1295
  ) : (
1218
1296
  <>
1219
1297
  {type === 'loading' ? <Circle cx={12} cy={12} r={9} {...stroke} stroke={alpha(color, 0.2)} /> : null}
@@ -1225,32 +1303,32 @@ function LivingIcon({
1225
1303
  rotation={-90}
1226
1304
  origin="12, 12"
1227
1305
  strokeDasharray={[per, per]}
1228
- strokeDashoffset={outline.interpolate({ inputRange: [0, 1], outputRange: [per, 0] })}
1306
+ animatedProps={outlineProps}
1229
1307
  />
1230
1308
  </>
1231
1309
  )}
1232
1310
  </Svg>
1233
1311
  </Animated.View>
1234
1312
  {type !== 'loading' ? (
1235
- <Animated.View style={[styles.abs, { left: inset, top: inset, transform: [{ scale: pop }] }]}>
1313
+ <Animated.View style={[styles.abs, { left: inset, top: inset }, popStyle]}>
1236
1314
  <Svg width={ICON} height={ICON} viewBox="0 0 24 24">
1237
- {type === 'success' ? <AnimatedPath d="M8 12.4l2.7 2.7L16.2 9.4" {...stroke} {...dash(12)} /> : null}
1315
+ {type === 'success' ? <DrawPath d="M8 12.4l2.7 2.7L16.2 9.4" len={12} draw={draw} stroke={stroke} /> : null}
1238
1316
  {type === 'error' ? (
1239
1317
  <>
1240
- <AnimatedPath d="M9.2 9.2l5.6 5.6" {...stroke} {...dash(8, 0, 0.55)} />
1241
- <AnimatedPath d="M14.8 9.2l-5.6 5.6" {...stroke} {...dash(8, 0.45, 1)} />
1318
+ <DrawPath d="M9.2 9.2l5.6 5.6" len={8} to={0.55} draw={draw} stroke={stroke} />
1319
+ <DrawPath d="M14.8 9.2l-5.6 5.6" len={8} from={0.45} draw={draw} stroke={stroke} />
1242
1320
  </>
1243
1321
  ) : null}
1244
1322
  {type === 'warning' ? (
1245
1323
  <>
1246
- <AnimatedPath d="M12 9.5v4" {...stroke} {...dash(4.2)} />
1247
- <AnimatedCircle cx={12} cy={16.6} r={1.25} fill={color} opacity={dot} />
1324
+ <DrawPath d="M12 9.5v4" len={4.2} draw={draw} stroke={stroke} />
1325
+ <DrawDot cy={16.6} color={color} draw={draw} />
1248
1326
  </>
1249
1327
  ) : null}
1250
1328
  {type === 'info' ? (
1251
1329
  <>
1252
- <AnimatedCircle cx={12} cy={7.9} r={1.25} fill={color} opacity={dot} />
1253
- <AnimatedPath d="M12 11v5" {...stroke} {...dash(5.2)} />
1330
+ <DrawDot cy={7.9} color={color} draw={draw} />
1331
+ <DrawPath d="M12 11v5" len={5.2} draw={draw} stroke={stroke} />
1254
1332
  </>
1255
1333
  ) : null}
1256
1334
  </Svg>
@@ -1277,7 +1355,7 @@ const styles = StyleSheet.create({
1277
1355
  desc: { fontSize: 13.5, fontWeight: '400', lineHeight: 19 },
1278
1356
  descMinimal: { fontSize: 13, lineHeight: 17 },
1279
1357
  action: { paddingHorizontal: 12, minHeight: 32, alignItems: 'center', justifyContent: 'center' },
1280
- actionText: { fontSize: 13, fontWeight: '400' },
1358
+ actionText: { fontSize: 13, fontWeight: '400', includeFontPadding: false, textAlignVertical: 'center' },
1281
1359
  close: { alignSelf: 'flex-start', padding: 2, marginRight: -4 },
1282
1360
  bar: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 2, transformOrigin: 'left' },
1283
1361
  abs: { position: 'absolute' },
@@ -1287,7 +1365,7 @@ const styles = StyleSheet.create({
1287
1365
  islandPad: { paddingVertical: 14, paddingHorizontal: 20 },
1288
1366
  islandPadIcon: { paddingTop: 13, paddingBottom: 15, paddingLeft: 14, paddingRight: 18 },
1289
1367
  islandChip: { paddingHorizontal: 10, minHeight: 24, justifyContent: 'center' },
1290
- islandChipText: { fontSize: 12.5, fontWeight: '400' },
1368
+ islandChipText: { fontSize: 12.5, fontWeight: '400', includeFontPadding: false, textAlignVertical: 'center' },
1291
1369
  islandActions: { paddingHorizontal: 14, paddingBottom: 16, marginTop: -2 },
1292
1370
  islandAction: { minHeight: 44, alignItems: 'center', justifyContent: 'center' },
1293
1371
  islandTrack: { position: 'absolute', left: 28, right: 28, bottom: 6, height: 2, borderRadius: 1, overflow: 'hidden' },