@rootnative/inertia 0.0.0-alpha.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 (113) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -0
  4. package/dist/gestureLayer/index.d.mts +119 -0
  5. package/dist/gestureLayer/index.d.ts +119 -0
  6. package/dist/gestureLayer/index.js +346 -0
  7. package/dist/gestureLayer/index.js.map +1 -0
  8. package/dist/gestureLayer/index.mjs +344 -0
  9. package/dist/gestureLayer/index.mjs.map +1 -0
  10. package/dist/index.d.mts +507 -0
  11. package/dist/index.d.ts +507 -0
  12. package/dist/index.js +1546 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/index.mjs +1515 -0
  15. package/dist/index.mjs.map +1 -0
  16. package/dist/motion/Image.d.mts +12 -0
  17. package/dist/motion/Image.d.ts +12 -0
  18. package/dist/motion/Image.js +1138 -0
  19. package/dist/motion/Image.js.map +1 -0
  20. package/dist/motion/Image.mjs +1132 -0
  21. package/dist/motion/Image.mjs.map +1 -0
  22. package/dist/motion/Pressable.d.mts +15 -0
  23. package/dist/motion/Pressable.d.ts +15 -0
  24. package/dist/motion/Pressable.js +1138 -0
  25. package/dist/motion/Pressable.js.map +1 -0
  26. package/dist/motion/Pressable.mjs +1132 -0
  27. package/dist/motion/Pressable.mjs.map +1 -0
  28. package/dist/motion/ScrollView.d.mts +12 -0
  29. package/dist/motion/ScrollView.d.ts +12 -0
  30. package/dist/motion/ScrollView.js +1138 -0
  31. package/dist/motion/ScrollView.js.map +1 -0
  32. package/dist/motion/ScrollView.mjs +1132 -0
  33. package/dist/motion/ScrollView.mjs.map +1 -0
  34. package/dist/motion/Text.d.mts +11 -0
  35. package/dist/motion/Text.d.ts +11 -0
  36. package/dist/motion/Text.js +1138 -0
  37. package/dist/motion/Text.js.map +1 -0
  38. package/dist/motion/Text.mjs +1132 -0
  39. package/dist/motion/Text.mjs.map +1 -0
  40. package/dist/motion/View.d.mts +11 -0
  41. package/dist/motion/View.d.ts +11 -0
  42. package/dist/motion/View.js +1138 -0
  43. package/dist/motion/View.js.map +1 -0
  44. package/dist/motion/View.mjs +1132 -0
  45. package/dist/motion/View.mjs.map +1 -0
  46. package/dist/testing/index.d.mts +57 -0
  47. package/dist/testing/index.d.ts +57 -0
  48. package/dist/testing/index.js +19 -0
  49. package/dist/testing/index.js.map +1 -0
  50. package/dist/testing/index.mjs +16 -0
  51. package/dist/testing/index.mjs.map +1 -0
  52. package/dist/touch/index.d.mts +146 -0
  53. package/dist/touch/index.d.ts +146 -0
  54. package/dist/touch/index.js +166 -0
  55. package/dist/touch/index.js.map +1 -0
  56. package/dist/touch/index.mjs +164 -0
  57. package/dist/touch/index.mjs.map +1 -0
  58. package/dist/types-cU43dEmH.d.mts +339 -0
  59. package/dist/types-cU43dEmH.d.ts +339 -0
  60. package/dist/useGesture-BnY65PlQ.d.ts +84 -0
  61. package/dist/useGesture-DxtXdz-K.d.mts +84 -0
  62. package/jest-preset.js +33 -0
  63. package/jest-setup.js +213 -0
  64. package/llms.txt +142 -0
  65. package/package.json +151 -0
  66. package/src/__type-tests__/animate.test-d.tsx +88 -0
  67. package/src/__type-tests__/variants.test-d.tsx +67 -0
  68. package/src/config/MotionConfig.tsx +30 -0
  69. package/src/config/MotionConfigContext.ts +53 -0
  70. package/src/config/index.ts +9 -0
  71. package/src/gestureLayer/index.ts +21 -0
  72. package/src/gestureLayer/useGestureLayer.ts +285 -0
  73. package/src/gestures/focusVisibility.ts +61 -0
  74. package/src/gestures/index.ts +1 -0
  75. package/src/index.ts +72 -0
  76. package/src/layout/index.ts +16 -0
  77. package/src/layout/resolveLayout.ts +54 -0
  78. package/src/layout/sharedRegistry.ts +111 -0
  79. package/src/layout/useSharedLayout.ts +289 -0
  80. package/src/motion/Image.tsx +9 -0
  81. package/src/motion/Pressable.tsx +12 -0
  82. package/src/motion/ScrollView.tsx +9 -0
  83. package/src/motion/Text.tsx +8 -0
  84. package/src/motion/View.tsx +8 -0
  85. package/src/motion/createMotionComponent.tsx +1306 -0
  86. package/src/motion/index.ts +26 -0
  87. package/src/motion/installCheck.ts +65 -0
  88. package/src/presence/Presence.tsx +165 -0
  89. package/src/presence/PresenceContext.ts +28 -0
  90. package/src/presence/index.ts +6 -0
  91. package/src/testing/index.ts +78 -0
  92. package/src/touch/index.ts +18 -0
  93. package/src/touch/useTouchDrag.ts +289 -0
  94. package/src/transitions/easing.ts +53 -0
  95. package/src/transitions/index.ts +7 -0
  96. package/src/transitions/keys.ts +32 -0
  97. package/src/transitions/resolve.ts +169 -0
  98. package/src/transitions/resolveSequence.ts +87 -0
  99. package/src/transitions/runtime.ts +63 -0
  100. package/src/transitions/sig.ts +40 -0
  101. package/src/transitions/spring.ts +42 -0
  102. package/src/types.ts +383 -0
  103. package/src/values/index.ts +26 -0
  104. package/src/values/useAnimation.ts +69 -0
  105. package/src/values/useBooleanSpring.ts +33 -0
  106. package/src/values/useColorTransition.ts +72 -0
  107. package/src/values/useGesture.ts +144 -0
  108. package/src/values/useMotionValue.ts +33 -0
  109. package/src/values/useScroll.ts +72 -0
  110. package/src/values/useShadow.ts +116 -0
  111. package/src/values/useSpring.ts +98 -0
  112. package/src/values/useTransform.ts +132 -0
  113. package/src/values/useVariants.ts +60 -0
@@ -0,0 +1,1306 @@
1
+ import {
2
+ type ComponentType,
3
+ forwardRef,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react'
9
+ import Animated, {
10
+ interpolateColor,
11
+ runOnJS,
12
+ useAnimatedStyle,
13
+ useSharedValue,
14
+ type SharedValue,
15
+ } from 'react-native-reanimated'
16
+ import { type LayoutChangeEvent } from 'react-native'
17
+ import { useShouldReduceMotion } from '../config'
18
+ import { isFocusVisible } from '../gestures'
19
+ import {
20
+ resolveLayoutTransition,
21
+ type LayoutProp,
22
+ useSharedLayout,
23
+ } from '../layout'
24
+ import { usePresence } from '../presence'
25
+ import {
26
+ isTopLevelTransition,
27
+ resolveAnimatableValue,
28
+ resolveTransition,
29
+ stableSig,
30
+ } from '../transitions'
31
+ import { ensureReanimatedInstalled } from './installCheck'
32
+ import {
33
+ type AnimatableValue,
34
+ type AnimateStyle,
35
+ type AnimationCallbackInfo,
36
+ type GestureLayerTransitions,
37
+ type GestureSubStates,
38
+ type MotionComponent,
39
+ type MotionProps,
40
+ type PerPropertyTransition,
41
+ type Transition,
42
+ type TransitionConfig,
43
+ type VariantController,
44
+ type VariantsMap,
45
+ } from '../types'
46
+
47
+ /**
48
+ * Animatable properties supported in the alpha. Expanding this set is a
49
+ * mechanical change — add the key here, decide whether it lives inside
50
+ * `transform`, and wire it through `buildAnimatedStyle` below.
51
+ */
52
+ const TRANSFORM_KEYS = [
53
+ 'translateX',
54
+ 'translateY',
55
+ 'scale',
56
+ 'scaleX',
57
+ 'scaleY',
58
+ 'rotate',
59
+ 'rotateX',
60
+ 'rotateY',
61
+ ] as const
62
+
63
+ // Rotation keys land in the transform array as `{ rotate: '45deg' }` / `{
64
+ // rotateX: '45deg' }` etc. — Reanimated needs the unit-suffixed string form.
65
+ // We hold the underlying shared value as a plain number (degrees) and wrap
66
+ // in the worklet so the resolver pipeline stays uniform with the other
67
+ // numeric transform keys.
68
+ const ROTATION_KEYS = new Set<string>(['rotate', 'rotateX', 'rotateY'])
69
+
70
+ const NUMERIC_TOP_LEVEL_KEYS = [
71
+ 'opacity',
72
+ 'width',
73
+ 'height',
74
+ 'borderRadius',
75
+ 'shadowOpacity',
76
+ 'shadowRadius',
77
+ 'elevation',
78
+ ] as const
79
+
80
+ // Color-valued keys. Reanimated's value setter detects color strings and
81
+ // interpolates between their packed RGBA representations natively in
82
+ // `withSpring` / `withTiming` — so the resolver path is identical to numeric
83
+ // keys; only the shared-value seed and the resting default differ.
84
+ //
85
+ // `tintColor` is Image-only, but allocated unconditionally here: the
86
+ // per-primitive type system (`AnimateStyle<C>`) is what gates which keys
87
+ // `animate` accepts at compile time. An unused shared value is a single ref;
88
+ // allocating it everywhere keeps hook order stable and the factory generic.
89
+ const COLOR_KEYS = [
90
+ 'backgroundColor',
91
+ 'borderColor',
92
+ 'color',
93
+ 'tintColor',
94
+ 'shadowColor',
95
+ ] as const
96
+
97
+ // Synthetic axes for the `shadowOffset: { width, height }` nested-object style
98
+ // prop. RN doesn't surface these as top-level keys; the worklet recomposes
99
+ // `shadowOffset` from the two synthetic SVs before emitting the style. The
100
+ // consumer's animate value (`shadowOffset: { width, height }`) decomposes into
101
+ // these at the activation / value-driving boundary; consumers don't write
102
+ // these keys directly.
103
+ const SHADOW_OFFSET_KEYS = ['shadowOffsetWidth', 'shadowOffsetHeight'] as const
104
+
105
+ /**
106
+ * Per-effect transform-group coordinator. Counts how many transform-axis
107
+ * terminal callbacks are still pending; when the last one fires, the
108
+ * factory emits a single coalesced `onAnimationEnd({ key: 'transform' })`
109
+ * instead of N per-axis callbacks. Mutated by the dispatch closure.
110
+ */
111
+ type TransformGroup = { remaining: number }
112
+
113
+ const ALL_KEYS = [
114
+ ...TRANSFORM_KEYS,
115
+ ...NUMERIC_TOP_LEVEL_KEYS,
116
+ ...COLOR_KEYS,
117
+ ...SHADOW_OFFSET_KEYS,
118
+ ] as const
119
+ type AnimatableKey = (typeof ALL_KEYS)[number]
120
+ type TransformKey = (typeof TRANSFORM_KEYS)[number]
121
+ type ShadowOffsetKey = (typeof SHADOW_OFFSET_KEYS)[number]
122
+
123
+ const TRANSFORM_KEY_SET = new Set<AnimatableKey>(TRANSFORM_KEYS)
124
+ const COLOR_KEY_SET = new Set<AnimatableKey>(COLOR_KEYS)
125
+ const SHADOW_OFFSET_KEY_SET = new Set<AnimatableKey>(SHADOW_OFFSET_KEYS)
126
+
127
+ const GESTURE_LAYER_NAMES = [
128
+ 'hovered',
129
+ 'focused',
130
+ 'focusVisible',
131
+ 'pressed',
132
+ ] as const
133
+ type GestureLayerName = (typeof GESTURE_LAYER_NAMES)[number]
134
+ const GESTURE_LAYER_NAME_SET = new Set<string>(GESTURE_LAYER_NAMES)
135
+
136
+ // Stable style object applied while a Motion primitive is mid-exit so taps
137
+ // fall through. Hoisted so every render shares the same reference and
138
+ // Reanimated's style merging treats it as a no-op when present.
139
+ const EXITING_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
140
+
141
+ const DEFAULT_RESTING: Record<AnimatableKey, number | string> = {
142
+ translateX: 0,
143
+ translateY: 0,
144
+ scale: 1,
145
+ scaleX: 1,
146
+ scaleY: 1,
147
+ rotate: 0,
148
+ rotateX: 0,
149
+ rotateY: 0,
150
+ opacity: 1,
151
+ width: 0,
152
+ height: 0,
153
+ borderRadius: 0,
154
+ shadowOpacity: 0,
155
+ shadowRadius: 0,
156
+ elevation: 0,
157
+ // 'transparent' is the only safe universal default for colors: it works as
158
+ // an initial seed for any color animation (no jarring opaque flash on mount
159
+ // when `initial` is omitted) and rgba(0,0,0,0) interpolates cleanly into
160
+ // any opaque target via Reanimated's color util.
161
+ backgroundColor: 'transparent',
162
+ borderColor: 'transparent',
163
+ color: 'transparent',
164
+ tintColor: 'transparent',
165
+ shadowColor: 'transparent',
166
+ shadowOffsetWidth: 0,
167
+ shadowOffsetHeight: 0,
168
+ }
169
+
170
+ function transitionFor<S>(
171
+ prop: keyof S,
172
+ transition: Transition<S> | undefined,
173
+ ): TransitionConfig | undefined {
174
+ if (!transition) return undefined
175
+ if (isTopLevelTransition(transition)) return transition
176
+ // Gesture-layer keys (`pressed`, `hovered`, …) live on the same map as
177
+ // per-property keys; skip them when looking up a property transition so a
178
+ // user who wires `transition.pressed` doesn't accidentally apply that to a
179
+ // style key named `pressed` (none currently exist, but keep the lookup
180
+ // honest).
181
+ if (GESTURE_LAYER_NAME_SET.has(prop as string)) return undefined
182
+ return (transition as PerPropertyTransition<S>)[prop]
183
+ }
184
+
185
+ function gestureLayerTransitionFor<S>(
186
+ layer: GestureLayerName,
187
+ transition: Transition<S> | undefined,
188
+ ): TransitionConfig | undefined {
189
+ if (!transition) return undefined
190
+ if (isTopLevelTransition(transition)) return transition
191
+ return (transition as GestureLayerTransitions)[layer]
192
+ }
193
+
194
+ /**
195
+ * Factory that wraps a React Native primitive as a `Motion.*` component.
196
+ *
197
+ * The generic `C` flows through `MotionProps`, so `animate` / `initial` /
198
+ * `exit` / `transition` all infer from `C`'s `style` prop. There is no
199
+ * shared `ViewStyle & TextStyle & ImageStyle` fallback.
200
+ *
201
+ * Alpha scope: numeric properties (transforms, opacity, width, height,
202
+ * borderRadius) and color properties (backgroundColor, borderColor, color,
203
+ * tintColor) applied via Reanimated shared values + `useAnimatedStyle`.
204
+ */
205
+ // `ComponentType<any>` is React's canonical "accept any component" idiom.
206
+ // `unknown` doesn't work — props need to widen to whatever `C` actually accepts
207
+ // at the call site. The two `any` uses below are deliberate.
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
+ export function createMotionComponent<C extends ComponentType<any>>(
210
+ Component: C,
211
+ ): MotionComponent<C> {
212
+ ensureReanimatedInstalled()
213
+
214
+ const AnimatedComponent = Animated.createAnimatedComponent(
215
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
216
+ Component as ComponentType<any>,
217
+ )
218
+
219
+ type Props = React.ComponentProps<C> & MotionProps<React.ComponentProps<C>>
220
+
221
+ const Motion = forwardRef<unknown, Props>(function Motion(props, ref) {
222
+ const {
223
+ initial,
224
+ animate,
225
+ exit,
226
+ transition,
227
+ variants,
228
+ controller,
229
+ gesture,
230
+ layout,
231
+ layoutId,
232
+ onAnimationEnd,
233
+ style,
234
+ onLayout: userOnLayout,
235
+ ...rest
236
+ } = props as Props & {
237
+ style?: unknown
238
+ layout?: LayoutProp
239
+ layoutId?: string
240
+ onLayout?: (event: LayoutChangeEvent) => void
241
+ }
242
+
243
+ // Function-form `style={(state) => ...}` is the Pressable render-prop API.
244
+ // Inertia drives press/focus state through `gesture.*` and merges its own
245
+ // animated style; a function passed here lands inside a style array where
246
+ // the underlying component never invokes it, so the resulting styles are
247
+ // silently dropped. Throw loudly in dev rather than ship the footgun.
248
+ if (__DEV__ && typeof style === 'function') {
249
+ throw new Error(
250
+ '[inertia] `style` must be a style object or array of style objects, ' +
251
+ 'not a function. The function-form `style={(state) => ...}` Pressable ' +
252
+ 'API is not supported — use `gesture.pressed` (or `gesture.focused`, ' +
253
+ 'etc.) to drive state-dependent styling instead.',
254
+ )
255
+ }
256
+
257
+ // <Presence> contract: when an ancestor flips `isPresent` to false the
258
+ // child stays rendered until `safeToRemove` is called, giving the exit
259
+ // animation time to play. `null` when there is no <Presence> ancestor.
260
+ const presence = usePresence()
261
+ const isExiting = presence !== null && presence.isPresent === false
262
+
263
+ // Resolved reduced-motion preference for this subtree. When true, every
264
+ // per-key transition is replaced with `no-animation` below, so values
265
+ // snap to target without interpolation. In 'user' mode the OS setting is
266
+ // read via Reanimated's `useReducedMotion`, which captures the value once
267
+ // at app start — a runtime toggle takes effect on the next launch.
268
+ const shouldReduceMotion = useShouldReduceMotion()
269
+
270
+ // Pin the latest `onAnimationEnd` in a ref so the worklet callback always
271
+ // dispatches against the current closure without re-resolving the
272
+ // animation graph. Worklets can read refs via `runOnJS`.
273
+ const onAnimationEndRef = useRef(onAnimationEnd)
274
+ onAnimationEndRef.current = onAnimationEnd
275
+
276
+ // Resolve `animate` against `variants` / `controller`. The controller's
277
+ // `current` wins when both are set (typed contract: don't mix
278
+ // `controller` and `animate` — controller drives the animation in that
279
+ // mode). When `animate` is a string and `variants` exist, look it up.
280
+ const variantKey = useControllerKey(controller)
281
+ const resolvedAnimate = resolveAnimateInput(
282
+ animate as AnimateStyle<unknown> | string | undefined,
283
+ variants as VariantsMap<unknown> | undefined,
284
+ variantKey,
285
+ )
286
+
287
+ const animateRecord = (resolvedAnimate ?? {}) as InternalAnimateRecord
288
+ const initialRecord =
289
+ initial && initial !== false
290
+ ? (initial as InternalInitialRecord)
291
+ : undefined
292
+ const exitRecord = exit ? (exit as InternalAnimateRecord) : undefined
293
+
294
+ // Gesture sub-state activation tracked as JS state. Activation flips drive
295
+ // the per-layer progress shared values (0↔1); they intentionally do NOT
296
+ // re-run the value-driving effect — gesture sub-state targets live on the
297
+ // worklet's composition chain, not on the base `animate` SV.
298
+ const [pressed, setPressed] = useState(false)
299
+ const [focused, setFocused] = useState(false)
300
+ const [focusVisible, setFocusVisible] = useState(false)
301
+ const [hovered, setHovered] = useState(false)
302
+
303
+ // The set of keys this instance animates is a *monotonically growing*
304
+ // union, recomputed every render and expanded when a render introduces a
305
+ // key not seen before. It never shrinks. Two requirements meet here:
306
+ //
307
+ // 1. Variants and gesture sub-states contribute the union across *all*
308
+ // their branches up front — a key touched by any variant must be
309
+ // active so the worklet picks it up when the controller transitions
310
+ // to a branch the base `animate` never mentions.
311
+ // 2. A literal `animate` object is reactive: a parent that changes
312
+ // `animate={{ opacity: 1 }}` to `animate={{ opacity: 1, scale: 2 }}`
313
+ // after mount must get `scale` animating. Freezing the set at first
314
+ // render silently dropped the new key (its SV updated, but the
315
+ // worklet — which iterates this set — never read it).
316
+ //
317
+ // Growing-only keeps the worklet stable: the `activeKeysRef.current` array
318
+ // identity only changes on the renders that actually add a key, so the
319
+ // `useAnimatedStyle` worklet (which reads `.current` each frame) sees the
320
+ // expansion without churning frame-to-frame.
321
+ const touched = new Set<AnimatableKey>()
322
+ collectTouchedKeys(touched, animateRecord)
323
+ if (initialRecord) collectTouchedKeys(touched, initialRecord)
324
+ if (variants) {
325
+ for (const variant of Object.values(variants) as object[]) {
326
+ if (!variant) continue
327
+ collectTouchedKeys(touched, variant as Record<string, unknown>)
328
+ }
329
+ }
330
+ if (gesture) {
331
+ for (const subState of [
332
+ gesture.pressed,
333
+ gesture.focused,
334
+ gesture.focusVisible,
335
+ gesture.hovered,
336
+ ] as Array<object | undefined>) {
337
+ if (!subState) continue
338
+ collectTouchedKeys(touched, subState as Record<string, unknown>)
339
+ }
340
+ }
341
+ if (exitRecord) collectTouchedKeys(touched, exitRecord)
342
+
343
+ const activeKeysRef = useRef<readonly AnimatableKey[] | null>(null)
344
+ const hasTransformRef = useRef<boolean>(false)
345
+ const hasShadowOffsetRef = useRef<boolean>(false)
346
+ // Expand the active set only when this render touched a key we haven't
347
+ // recorded yet. When nothing new appears we keep the existing array
348
+ // identity so the worklet's captured ref doesn't see a fresh value.
349
+ const prevActive = activeKeysRef.current
350
+ let grew = prevActive === null
351
+ if (!grew && prevActive) {
352
+ for (const k of touched) {
353
+ if (!prevActive.includes(k)) {
354
+ grew = true
355
+ break
356
+ }
357
+ }
358
+ }
359
+ if (grew) {
360
+ const merged = new Set<AnimatableKey>(prevActive ?? [])
361
+ for (const k of touched) merged.add(k)
362
+ activeKeysRef.current = ALL_KEYS.filter((k) => merged.has(k))
363
+ hasTransformRef.current = activeKeysRef.current.some((k) =>
364
+ TRANSFORM_KEY_SET.has(k),
365
+ )
366
+ hasShadowOffsetRef.current = activeKeysRef.current.some((k) =>
367
+ SHADOW_OFFSET_KEY_SET.has(k),
368
+ )
369
+ }
370
+
371
+ const sharedValues = useAnimatableSharedValues((key) => {
372
+ // Shadow offset synthetics seed from the corresponding axis on the
373
+ // `shadowOffset: { width, height }` source — the consumer doesn't write
374
+ // `shadowOffsetWidth` / `shadowOffsetHeight` directly. Fall back to the
375
+ // generic resting default when neither initial nor animate touched it.
376
+ if (SHADOW_OFFSET_KEY_SET.has(key)) {
377
+ const axis = shadowOffsetAxisFor(key as ShadowOffsetKey)
378
+ if (initial === false) {
379
+ return (
380
+ shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
381
+ DEFAULT_RESTING[key]
382
+ )
383
+ }
384
+ return (
385
+ shadowOffsetAxisValue(
386
+ initialRecord?.shadowOffset as
387
+ | { width?: number; height?: number }
388
+ | undefined,
389
+ axis,
390
+ ) ??
391
+ shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
392
+ DEFAULT_RESTING[key]
393
+ )
394
+ }
395
+ if (initial === false) {
396
+ const a = animateRecord[key]
397
+ return restValue(a) ?? DEFAULT_RESTING[key]
398
+ }
399
+ return (
400
+ initialRecord?.[key] ??
401
+ restValue(animateRecord[key]) ??
402
+ DEFAULT_RESTING[key]
403
+ )
404
+ })
405
+
406
+ // One progress SV per gesture layer, allocated unconditionally for hook
407
+ // stability. Each layer's progress animates 0↔1 with its own transition
408
+ // when its activation flips; the worklet reads them when compositing.
409
+ // Initial value is 0 — even if a sub-state is somehow active on mount,
410
+ // the activation effect below will animate it to 1 on the next tick.
411
+ const pressedProgress = useSharedValue(0)
412
+ const focusedProgress = useSharedValue(0)
413
+ const focusVisibleProgress = useSharedValue(0)
414
+ const hoveredProgress = useSharedValue(0)
415
+
416
+ // Mirror gesture targets into a UI-runtime-resident shared value so the
417
+ // animated-style worklet can read the latest layer values without having
418
+ // to capture `gesture` directly (which would re-register the worklet on
419
+ // every render where the consumer passes a fresh literal). The signature
420
+ // dependency means we only push to the SV when targets actually change —
421
+ // the SV ref itself is stable across renders.
422
+ //
423
+ // The resolved value is a layer-keyed map of primitive endpoints (numbers
424
+ // or color strings); sequence/`{ to }` step shapes on a sub-state collapse
425
+ // to their final endpoint via `targetEndValue` because a gesture layer
426
+ // describes a steady target, not a keyframe sequence.
427
+ const gestureSV = useSharedValue<ResolvedGestureLayers | null>(
428
+ resolveGestureLayers(gesture),
429
+ )
430
+ const gestureTargetsSig = stableSig(gesture)
431
+ useEffect(() => {
432
+ gestureSV.value = resolveGestureLayers(gesture)
433
+ // eslint-disable-next-line react-hooks/exhaustive-deps
434
+ }, [gestureTargetsSig])
435
+
436
+ // The base record drives the per-key shared values. Gesture sub-state
437
+ // targets are intentionally NOT merged here — they layer on top in the
438
+ // worklet. Exit values still take precedence over `animate` while exiting
439
+ // because the base SV is what <Presence> waits on to settle.
440
+ const baseRecord =
441
+ isExiting && exitRecord
442
+ ? { ...animateRecord, ...exitRecord }
443
+ : animateRecord
444
+ const baseSig =
445
+ stableSig(baseRecord) +
446
+ (isExiting ? '|exit' : '') +
447
+ (shouldReduceMotion ? '|rm' : '')
448
+ const transitionSig = stableSig(transition)
449
+
450
+ // Stable ref to the live `safeToRemove` so the effect's settle-counter
451
+ // closure can reach the latest <Presence> binding without retriggering.
452
+ const safeToRemoveRef = useRef<(() => void) | undefined>(undefined)
453
+ safeToRemoveRef.current = presence?.safeToRemove
454
+
455
+ useEffect(() => {
456
+ // Exit fast-path: nothing to animate (or no exit prop), tell <Presence>
457
+ // immediately so the unmount isn't gated on a phantom animation.
458
+ if (isExiting && (!exitRecord || Object.keys(exitRecord).length === 0)) {
459
+ safeToRemoveRef.current?.()
460
+ return
461
+ }
462
+
463
+ let pending = 0
464
+ let done = false
465
+ const onSettle = () => {
466
+ if (done) return
467
+ pending--
468
+ if (pending <= 0) {
469
+ done = true
470
+ if (isExiting) safeToRemoveRef.current?.()
471
+ }
472
+ }
473
+
474
+ // Count transform axes participating in this effect run so the factory
475
+ // can coalesce their terminal callbacks into a single transform-group
476
+ // event. `undefined` when no transform axis is animating, which lets
477
+ // the factory skip the coalescing branch entirely.
478
+ let transformPending = 0
479
+ for (const k of ALL_KEYS) {
480
+ if (TRANSFORM_KEY_SET.has(k) && baseRecord[k] !== undefined) {
481
+ transformPending++
482
+ }
483
+ }
484
+ const transformGroup: TransformGroup | undefined =
485
+ transformPending > 0 ? { remaining: transformPending } : undefined
486
+
487
+ for (const key of ALL_KEYS) {
488
+ // Shadow offset synthetics read their target from the nested
489
+ // `shadowOffset: { width, height }` source on `baseRecord` — the
490
+ // animate / exit record never has `shadowOffsetWidth` etc. on it
491
+ // directly. The synthetic transition follows the same `shadowOffset`
492
+ // top-level transition entry (no per-axis split).
493
+ const target: AnimatableValue<number | string> | undefined =
494
+ SHADOW_OFFSET_KEY_SET.has(key)
495
+ ? shadowOffsetAxisValue(
496
+ baseRecord.shadowOffset,
497
+ shadowOffsetAxisFor(key as ShadowOffsetKey),
498
+ )
499
+ : baseRecord[key]
500
+ if (target === undefined) continue
501
+ // Reduced-motion overrides every per-key transition (and any nested
502
+ // sequence-step transition) with `no-animation`, which the resolver
503
+ // turns into a direct value assignment. Sequences still iterate but
504
+ // each step settles instantly, which matches the "snap to final
505
+ // state" expectation.
506
+ const cfg = shouldReduceMotion
507
+ ? ({ type: 'no-animation' } as const)
508
+ : transitionFor(
509
+ SHADOW_OFFSET_KEY_SET.has(key)
510
+ ? ('shadowOffset' as keyof typeof baseRecord)
511
+ : key,
512
+ transition,
513
+ )
514
+ if (isExiting) pending++
515
+ const factory = makeKeyCallbackFactory(
516
+ key,
517
+ sharedValues[key],
518
+ targetEndValue(target),
519
+ onAnimationEndRef,
520
+ {
521
+ stepCount: stepCountOf(target),
522
+ totalIterations: totalIterationsOf(cfg),
523
+ },
524
+ isExiting ? onSettle : undefined,
525
+ TRANSFORM_KEY_SET.has(key) ? transformGroup : undefined,
526
+ )
527
+ sharedValues[key].value = resolveAnimatableValue(
528
+ target,
529
+ cfg,
530
+ factory,
531
+ ) as never
532
+ }
533
+
534
+ // No exit-targeted keys (only `animate` keys present, no `exit`)
535
+ // → release immediately rather than wait for animations that aren't
536
+ // headed toward an exit value.
537
+ if (isExiting && pending === 0) {
538
+ safeToRemoveRef.current?.()
539
+ }
540
+ // eslint-disable-next-line react-hooks/exhaustive-deps
541
+ }, [baseSig, transitionSig])
542
+
543
+ // Per-layer progress: when a sub-state activation flips, animate its
544
+ // progress SV 0↔1 with the layer's own transition (or the parent
545
+ // transition / library default, in priority order). On exit we snap every
546
+ // layer to 0 instantly so the unmount-bound base SV isn't fighting a
547
+ // stale layer contribution mid-fade.
548
+ //
549
+ // The `declared` flag short-circuits the effect when the consumer hasn't
550
+ // wired the corresponding sub-state — so a Motion primitive without a
551
+ // `gesture` prop (or with only some sub-states declared) makes zero extra
552
+ // `withSpring` / `withTiming` calls on mount.
553
+ useGestureLayerProgress(
554
+ pressedProgress,
555
+ pressed,
556
+ gesture?.pressed != null,
557
+ 'pressed',
558
+ transition,
559
+ isExiting,
560
+ shouldReduceMotion,
561
+ )
562
+ useGestureLayerProgress(
563
+ focusedProgress,
564
+ focused,
565
+ gesture?.focused != null,
566
+ 'focused',
567
+ transition,
568
+ isExiting,
569
+ shouldReduceMotion,
570
+ )
571
+ useGestureLayerProgress(
572
+ focusVisibleProgress,
573
+ focusVisible,
574
+ gesture?.focusVisible != null,
575
+ 'focusVisible',
576
+ transition,
577
+ isExiting,
578
+ shouldReduceMotion,
579
+ )
580
+ useGestureLayerProgress(
581
+ hoveredProgress,
582
+ hovered,
583
+ gesture?.hovered != null,
584
+ 'hovered',
585
+ transition,
586
+ isExiting,
587
+ shouldReduceMotion,
588
+ )
589
+
590
+ // Shared-element transition wiring. `useSharedLayout` allocates FLIP
591
+ // shared values (identity at rest), measures via the merged `onLayout`,
592
+ // and on first-mount snaps the FLIP transform to a source rect popped
593
+ // from the registry. The worklet below appends those entries to the
594
+ // transform array so they compose with the user's animate transforms —
595
+ // multiple `translateX` entries sum, multiple `scaleX` entries multiply,
596
+ // which is exactly the FLIP semantic.
597
+ const sharedLayout = useSharedLayout({
598
+ layoutId,
599
+ userRef: ref,
600
+ transition: isTopLevelTransition(transition) ? transition : undefined,
601
+ shouldReduceMotion,
602
+ userOnLayout,
603
+ })
604
+ const flip = sharedLayout.flip
605
+ const hasLayoutId = layoutId !== undefined
606
+
607
+ const animatedStyle = useAnimatedStyle(() => {
608
+ const activeKeys = activeKeysRef.current!
609
+ const hasTransform = hasTransformRef.current
610
+ const hasShadowOffset = hasShadowOffsetRef.current
611
+ const out: Record<string, unknown> = {}
612
+ const transform: Array<Record<string, unknown>> = []
613
+ // shadow-offset reassembly buffers. The two synthetic axis SVs feed in
614
+ // here and the recomposed `{ width, height }` object lands on `out`
615
+ // after the loop so RN gets a single `shadowOffset` style prop.
616
+ let shadowOffsetW = 0
617
+ let shadowOffsetH = 0
618
+
619
+ // Read each progress SV exactly once so the chain below sees a coherent
620
+ // snapshot for this frame. Reading them on the UI thread is cheap.
621
+ const ph = hoveredProgress.value
622
+ const pf = focusedProgress.value
623
+ const pfv = focusVisibleProgress.value
624
+ const pp = pressedProgress.value
625
+
626
+ const layers = gestureSV.value
627
+ // Locals are suffixed `Layer` so they don't shadow the outer `pressed` /
628
+ // `focused` / `focusVisible` / `hovered` JS-state booleans — Reanimated's
629
+ // worklet closure tracker would otherwise pick those up as captured
630
+ // dependencies and re-register the worklet on every activation flip.
631
+ const hoveredLayer = layers ? layers.hovered : null
632
+ const focusedLayer = layers ? layers.focused : null
633
+ const focusVisibleLayer = layers ? layers.focusVisible : null
634
+ const pressedLayer = layers ? layers.pressed : null
635
+
636
+ for (const key of activeKeys) {
637
+ let v = sharedValues[key].value
638
+ const isColor = COLOR_KEY_SET.has(key)
639
+
640
+ // Composite gesture layers in priority order (lowest first). Each
641
+ // active layer pulls the value toward its pre-resolved primitive
642
+ // endpoint by `progress`; numeric keys lerp, color keys go through
643
+ // Reanimated's RGBA `interpolateColor`. We skip layers with progress
644
+ // 0 to avoid an `interpolateColor(0, ...)` call that would parse the
645
+ // target color string for no visible effect.
646
+ if (hoveredLayer && ph > 0 && hoveredLayer[key] !== undefined) {
647
+ const t = hoveredLayer[key]
648
+ v = isColor
649
+ ? interpolateColor(ph, [0, 1], [v as string, t as string])
650
+ : (v as number) + ((t as number) - (v as number)) * ph
651
+ }
652
+ if (focusedLayer && pf > 0 && focusedLayer[key] !== undefined) {
653
+ const t = focusedLayer[key]
654
+ v = isColor
655
+ ? interpolateColor(pf, [0, 1], [v as string, t as string])
656
+ : (v as number) + ((t as number) - (v as number)) * pf
657
+ }
658
+ if (
659
+ focusVisibleLayer &&
660
+ pfv > 0 &&
661
+ focusVisibleLayer[key] !== undefined
662
+ ) {
663
+ const t = focusVisibleLayer[key]
664
+ v = isColor
665
+ ? interpolateColor(pfv, [0, 1], [v as string, t as string])
666
+ : (v as number) + ((t as number) - (v as number)) * pfv
667
+ }
668
+ if (pressedLayer && pp > 0 && pressedLayer[key] !== undefined) {
669
+ const t = pressedLayer[key]
670
+ v = isColor
671
+ ? interpolateColor(pp, [0, 1], [v as string, t as string])
672
+ : (v as number) + ((t as number) - (v as number)) * pp
673
+ }
674
+
675
+ if (TRANSFORM_KEY_SET.has(key)) {
676
+ transform.push(
677
+ ROTATION_KEYS.has(key) ? { [key]: `${v}deg` } : { [key]: v },
678
+ )
679
+ } else if (key === 'shadowOffsetWidth') {
680
+ shadowOffsetW = v as number
681
+ } else if (key === 'shadowOffsetHeight') {
682
+ shadowOffsetH = v as number
683
+ } else {
684
+ out[key] = v
685
+ }
686
+ }
687
+ // Shared-element FLIP transforms append after the user's transform
688
+ // entries so they compose multiplicatively in the same `transform`
689
+ // array — separate style entries with `transform` keys would
690
+ // last-write-wins, which is what we explicitly avoid here. At rest
691
+ // (dx, dy, sx, sy) = (0, 0, 1, 1) so the contribution is a no-op
692
+ // when no shared-element transition is active.
693
+ if (hasLayoutId) {
694
+ transform.push({ translateX: flip.dx.value })
695
+ transform.push({ translateY: flip.dy.value })
696
+ transform.push({ scaleX: flip.sx.value })
697
+ transform.push({ scaleY: flip.sy.value })
698
+ }
699
+ if (hasTransform || hasLayoutId) out.transform = transform
700
+ if (hasShadowOffset) {
701
+ out.shadowOffset = { width: shadowOffsetW, height: shadowOffsetH }
702
+ }
703
+ return out
704
+ })
705
+
706
+ // Exiting children are tap-deaf: the next press should fall through to
707
+ // whatever is underneath, not re-trigger a soon-to-unmount node. This is
708
+ // the moti #297 fix and a v0.1 acceptance criterion. RN 0.71+ deprecates
709
+ // `pointerEvents` as a prop in favor of the style key, so we merge it
710
+ // alongside the animated style instead of spreading as a prop.
711
+ const mergedStyle = useMemo(
712
+ () =>
713
+ (isExiting
714
+ ? [style, animatedStyle, EXITING_POINTER_EVENTS_STYLE]
715
+ : [style, animatedStyle]) as unknown,
716
+ [style, animatedStyle, isExiting],
717
+ )
718
+
719
+ const gestureHandlers = useGestureHandlers(
720
+ gesture,
721
+ rest as Record<string, unknown>,
722
+ setPressed,
723
+ setFocused,
724
+ setFocusVisible,
725
+ setHovered,
726
+ )
727
+
728
+ // Resolve the `layout` prop into a Reanimated `LinearTransition` builder.
729
+ // Memoized on the value's stable signature so a fresh `layout={true}` or
730
+ // `layout={{ ... }}` literal each render doesn't rebuild the builder. When
731
+ // reduced motion is active we pass `undefined` — see `resolveLayout` for
732
+ // why we don't pass a duration-0 builder instead.
733
+ const layoutSig = stableSig(layout)
734
+ const layoutTransition = useMemo(
735
+ () => (shouldReduceMotion ? undefined : resolveLayoutTransition(layout)),
736
+ // eslint-disable-next-line react-hooks/exhaustive-deps
737
+ [layoutSig, shouldReduceMotion],
738
+ )
739
+
740
+ return (
741
+ <AnimatedComponent
742
+ ref={sharedLayout.setRef as never}
743
+ {...(rest as object)}
744
+ {...gestureHandlers}
745
+ onLayout={sharedLayout.onLayout}
746
+ layout={layoutTransition}
747
+ style={mergedStyle}
748
+ />
749
+ )
750
+ })
751
+
752
+ Motion.displayName = `Motion(${Component.displayName ?? Component.name ?? 'Component'})`
753
+
754
+ return Motion as unknown as MotionComponent<C>
755
+ }
756
+
757
+ type SharedValueMap = Record<AnimatableKey, SharedValue<number | string>>
758
+
759
+ /**
760
+ * Allocate one shared value per animatable key in `ALL_KEYS` and return a
761
+ * **stable** map — same object reference across every render.
762
+ *
763
+ * Stability matters: `useAnimatedStyle` derives its dep array from
764
+ * `Object.values(updater.__closure)`. Our worklet captures `sharedValues`,
765
+ * so a fresh object literal each render would change that dep, fire
766
+ * Reanimated's effect, and re-bind the worklet on the UI thread on every
767
+ * render — the exact cost design principle 8 calls out. The shared values themselves
768
+ * are stable across renders (Reanimated's `useSharedValue` is a `useRef`
769
+ * under the hood), so snapshotting the wrapping object once is safe.
770
+ *
771
+ * Hooks are called in a stable, lexical order — fine for rules-of-hooks.
772
+ * Unused shared values are cheap; the worklet skips them via
773
+ * `activeKeysRef`. Color keys are seeded with the initial color string so
774
+ * Reanimated's value setter recognizes the slot as a color from the first
775
+ * `withSpring` / `withTiming` call.
776
+ */
777
+ function useAnimatableSharedValues(
778
+ init: (key: AnimatableKey) => number | string,
779
+ ): SharedValueMap {
780
+ const translateX = useSharedValue<number | string>(init('translateX'))
781
+ const translateY = useSharedValue<number | string>(init('translateY'))
782
+ const scale = useSharedValue<number | string>(init('scale'))
783
+ const scaleX = useSharedValue<number | string>(init('scaleX'))
784
+ const scaleY = useSharedValue<number | string>(init('scaleY'))
785
+ const rotate = useSharedValue<number | string>(init('rotate'))
786
+ const rotateX = useSharedValue<number | string>(init('rotateX'))
787
+ const rotateY = useSharedValue<number | string>(init('rotateY'))
788
+ const opacity = useSharedValue<number | string>(init('opacity'))
789
+ const width = useSharedValue<number | string>(init('width'))
790
+ const height = useSharedValue<number | string>(init('height'))
791
+ const borderRadius = useSharedValue<number | string>(init('borderRadius'))
792
+ const shadowOpacity = useSharedValue<number | string>(init('shadowOpacity'))
793
+ const shadowRadius = useSharedValue<number | string>(init('shadowRadius'))
794
+ const elevation = useSharedValue<number | string>(init('elevation'))
795
+ const backgroundColor = useSharedValue<number | string>(
796
+ init('backgroundColor'),
797
+ )
798
+ const borderColor = useSharedValue<number | string>(init('borderColor'))
799
+ const color = useSharedValue<number | string>(init('color'))
800
+ const tintColor = useSharedValue<number | string>(init('tintColor'))
801
+ const shadowColor = useSharedValue<number | string>(init('shadowColor'))
802
+ const shadowOffsetWidth = useSharedValue<number | string>(
803
+ init('shadowOffsetWidth'),
804
+ )
805
+ const shadowOffsetHeight = useSharedValue<number | string>(
806
+ init('shadowOffsetHeight'),
807
+ )
808
+
809
+ const ref = useRef<SharedValueMap | null>(null)
810
+ if (ref.current === null) {
811
+ ref.current = {
812
+ translateX,
813
+ translateY,
814
+ scale,
815
+ scaleX,
816
+ scaleY,
817
+ rotate,
818
+ rotateX,
819
+ rotateY,
820
+ opacity,
821
+ width,
822
+ height,
823
+ borderRadius,
824
+ shadowOpacity,
825
+ shadowRadius,
826
+ elevation,
827
+ backgroundColor,
828
+ borderColor,
829
+ color,
830
+ tintColor,
831
+ shadowColor,
832
+ shadowOffsetWidth,
833
+ shadowOffsetHeight,
834
+ }
835
+ }
836
+ return ref.current
837
+ }
838
+
839
+ /**
840
+ * Build a per-key `CallbackFactory` for the resolver. Each step in a sequence
841
+ * (or the single animation, when `value` isn't an array) gets its own
842
+ * Reanimated callback; when it settles on the UI thread, the callback bridges
843
+ * to JS via `runOnJS` and invokes the user's `onAnimationEnd` with a fully
844
+ * populated `AnimationCallbackInfo`.
845
+ *
846
+ * Phase resolution lives here, on the JS thread. The resolver hands us a
847
+ * coarse rawPhase (`'step'` for any sequence step, `'animation'` for a
848
+ * single-shot terminal); we map that onto the public phase set
849
+ * (`'step' | 'sequence' | 'repeat' | 'animation'`) using `meta` and the
850
+ * iteration counter. `iteration` resets per effect run because the factory
851
+ * is constructed fresh inside the effect.
852
+ */
853
+ function makeKeyCallbackFactory(
854
+ key: string,
855
+ sharedValue: SharedValue<number | string>,
856
+ target: number | string | undefined,
857
+ onAnimationEndRef: {
858
+ current: ((info: AnimationCallbackInfo<unknown>) => void) | undefined
859
+ },
860
+ meta: { stepCount: number; totalIterations: number },
861
+ onSettle?: () => void,
862
+ transformGroup?: TransformGroup,
863
+ ) {
864
+ if (!onAnimationEndRef.current && !onSettle) return undefined
865
+
866
+ // Shared across this animation graph's callbacks (one per sequence step,
867
+ // or one for a single-shot). Mutated when a full pass completes.
868
+ const state = { iteration: 0 }
869
+
870
+ const isTransformKey = TRANSFORM_KEY_SET.has(key as AnimatableKey)
871
+
872
+ const dispatch = (
873
+ rawPhase: 'step' | 'animation',
874
+ step: number | undefined,
875
+ finished: boolean,
876
+ value: number | string | undefined,
877
+ ) => {
878
+ const isLastIteration = state.iteration >= meta.totalIterations - 1
879
+ let phase: 'step' | 'sequence' | 'repeat' | 'animation'
880
+ let isTerminal = false
881
+
882
+ if (rawPhase === 'step') {
883
+ const isLastInPass = step !== undefined && step === meta.stepCount - 1
884
+ if (!isLastInPass) {
885
+ phase = 'step'
886
+ } else if (isLastIteration) {
887
+ phase = 'animation'
888
+ isTerminal = true
889
+ } else {
890
+ phase = 'sequence'
891
+ }
892
+ } else if (isLastIteration) {
893
+ phase = 'animation'
894
+ isTerminal = true
895
+ } else {
896
+ phase = 'repeat'
897
+ }
898
+
899
+ const reportedIteration = state.iteration
900
+ if (phase === 'sequence' || phase === 'repeat') state.iteration++
901
+
902
+ const fn = onAnimationEndRef.current
903
+ if (fn) {
904
+ // Transform-group coalescing: a multi-axis translate / scale /
905
+ // rotate animation should fire onAnimationEnd ONCE for the logical
906
+ // transform, not once per axis. We only coalesce the terminal
907
+ // `'animation'` phase — `step`/`sequence`/`repeat` events fire
908
+ // per-axis since each is its own logical event. Released per-axis
909
+ // for a single-axis case too, with `key: 'transform'` for
910
+ // consistency.
911
+ if (isTransformKey && transformGroup && phase === 'animation') {
912
+ transformGroup.remaining--
913
+ if (transformGroup.remaining <= 0) {
914
+ fn({
915
+ key: 'transform' as never,
916
+ finished,
917
+ value,
918
+ target,
919
+ phase,
920
+ step,
921
+ iteration: reportedIteration,
922
+ })
923
+ }
924
+ } else {
925
+ fn({
926
+ key: key as never,
927
+ finished,
928
+ value,
929
+ target,
930
+ phase,
931
+ step,
932
+ iteration: reportedIteration,
933
+ })
934
+ }
935
+ }
936
+ // Settle hooks fire per-axis on the terminal phase — <Presence> waits
937
+ // for *every* exiting property to settle before unmounting, so we
938
+ // intentionally do not coalesce these (the transform-group coalesce
939
+ // is purely a user-callback ergonomic).
940
+ if (onSettle && isTerminal) onSettle()
941
+ }
942
+
943
+ return (rawPhase: 'step' | 'animation', step: number | undefined) => {
944
+ // Reanimated invokes the callback with only `finished` (see
945
+ // valueSetter.js:24,40,51 in 4.x) — `current` is never passed. Read the
946
+ // shared value inside the worklet; by the time the callback fires the
947
+ // final/clamped value has already been written to it.
948
+ const cb = (finished?: boolean) => {
949
+ 'worklet'
950
+ runOnJS(dispatch)(rawPhase, step, !!finished, sharedValue.value)
951
+ }
952
+ return cb
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Internal shape of the `animate` / `exit` record after the cast, widened to
958
+ * include the `shadowOffset: { width, height }` nested-object source. The
959
+ * nested object decomposes into the `shadowOffsetWidth` / `shadowOffsetHeight`
960
+ * synthetic axes downstream; consumers don't see the synthetics.
961
+ *
962
+ * v0.1 contract: `shadowOffset` accepts a single `{ width, height }` literal
963
+ * (no sequences, no `{ to }` step objects, no array keyframes). Sequence
964
+ * forms on the nested axes can land in v0.2 if real consumers ask for them.
965
+ */
966
+ type InternalAnimateRecord = Partial<
967
+ Record<AnimatableKey, AnimatableValue<number | string>>
968
+ > & {
969
+ shadowOffset?: { width?: number; height?: number }
970
+ }
971
+
972
+ type InternalInitialRecord = Partial<Record<AnimatableKey, number | string>> & {
973
+ shadowOffset?: { width?: number; height?: number }
974
+ }
975
+
976
+ /**
977
+ * Resolve a `shadowOffsetWidth` / `shadowOffsetHeight` synthetic key to the
978
+ * axis it represents on the nested-object source.
979
+ */
980
+ function shadowOffsetAxisFor(key: ShadowOffsetKey): 'width' | 'height' {
981
+ return key === 'shadowOffsetWidth' ? 'width' : 'height'
982
+ }
983
+
984
+ /**
985
+ * Read a single axis off a `shadowOffset: { width, height }` source. Returns
986
+ * `undefined` when the source is absent or the axis isn't set, so callers can
987
+ * fall back to the next source in the precedence chain.
988
+ */
989
+ function shadowOffsetAxisValue(
990
+ source: { width?: number; height?: number } | undefined,
991
+ axis: 'width' | 'height',
992
+ ): number | undefined {
993
+ return source?.[axis]
994
+ }
995
+
996
+ /**
997
+ * Populate `touched` with the `AnimatableKey`s mentioned in `record`. Direct
998
+ * matches (e.g. `opacity`, `width`) come from the key iteration; the nested
999
+ * `shadowOffset` source decomposes into the two `shadowOffset*` synthetics.
1000
+ */
1001
+ function collectTouchedKeys(
1002
+ touched: Set<AnimatableKey>,
1003
+ record: Record<string, unknown>,
1004
+ ): void {
1005
+ for (const k of ALL_KEYS) {
1006
+ if (k in record) touched.add(k)
1007
+ }
1008
+ if ('shadowOffset' in record && record.shadowOffset) {
1009
+ const so = record.shadowOffset as { width?: unknown; height?: unknown }
1010
+ if (so.width !== undefined) touched.add('shadowOffsetWidth')
1011
+ if (so.height !== undefined) touched.add('shadowOffsetHeight')
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Number of sequence steps in an animatable value. `1` for plain values and
1017
+ * single-step `{ to }` objects; the array length for keyframe arrays.
1018
+ */
1019
+ function stepCountOf(v: AnimatableValue<number | string> | undefined): number {
1020
+ if (Array.isArray(v)) return v.length
1021
+ return 1
1022
+ }
1023
+
1024
+ /**
1025
+ * Total number of iterations the animation will run, including the initial
1026
+ * pass. `1` when there is no `repeat`; `Number.POSITIVE_INFINITY` for
1027
+ * `'infinite'`. Decay and `no-animation` configs cannot repeat — both return
1028
+ * `1` so the iteration counter stays at 0.
1029
+ */
1030
+ function totalIterationsOf(cfg: TransitionConfig | undefined): number {
1031
+ if (!cfg || cfg.type === 'no-animation' || cfg.type === 'decay') return 1
1032
+ const r = cfg.repeat
1033
+ if (r === undefined) return 1
1034
+ if (r === 'infinite') return Number.POSITIVE_INFINITY
1035
+ if (typeof r === 'number') return r
1036
+ if (r.count === 'infinite') return Number.POSITIVE_INFINITY
1037
+ return r.count
1038
+ }
1039
+
1040
+ /**
1041
+ * Pull a single end-value out of an `AnimatableValue` for the
1042
+ * `AnimationCallbackInfo.target` field. Plain numbers/strings come through;
1043
+ * the last sequence step's `to`/value is used for arrays; `{ to }` step
1044
+ * objects use `to`. Returns `undefined` for unrecognized shapes.
1045
+ */
1046
+ function targetEndValue(
1047
+ v: AnimatableValue<number | string> | undefined,
1048
+ ): number | string | undefined {
1049
+ if (v === undefined) return undefined
1050
+ if (typeof v === 'number' || typeof v === 'string') return v
1051
+ if (Array.isArray(v)) {
1052
+ return v.length > 0
1053
+ ? targetEndValue(v[v.length - 1] as AnimatableValue<number | string>)
1054
+ : undefined
1055
+ }
1056
+ if (typeof v === 'object' && v !== null && 'to' in v) {
1057
+ const to = (v as { to: unknown }).to
1058
+ return typeof to === 'number' || typeof to === 'string' ? to : undefined
1059
+ }
1060
+ return undefined
1061
+ }
1062
+
1063
+ /**
1064
+ * Subscribe to a `VariantController` and return its `current` key. Returns
1065
+ * `undefined` when no controller is provided so callers can fall back to a
1066
+ * literal `animate` value.
1067
+ */
1068
+ function useControllerKey(
1069
+ controller: VariantController | undefined,
1070
+ ): string | undefined {
1071
+ const [, setTick] = useState(0)
1072
+ useEffect(() => {
1073
+ if (!controller) return
1074
+ const unsub = controller.subscribe(() => setTick((n) => n + 1))
1075
+ return unsub
1076
+ }, [controller])
1077
+ return controller?.current
1078
+ }
1079
+
1080
+ /**
1081
+ * Resolve the effective `animate` target from the public-prop tuple.
1082
+ *
1083
+ * Precedence: `controller.current` (when controller is set) > string-keyed
1084
+ * `animate` looked up in `variants` > literal `animate` object > `undefined`.
1085
+ */
1086
+ function resolveAnimateInput(
1087
+ animate: AnimateStyle<unknown> | string | undefined,
1088
+ variants: VariantsMap<unknown> | undefined,
1089
+ controllerKey: string | undefined,
1090
+ ): AnimateStyle<unknown> | undefined {
1091
+ if (controllerKey !== undefined && variants && controllerKey in variants) {
1092
+ return variants[controllerKey]
1093
+ }
1094
+ if (typeof animate === 'string') {
1095
+ if (variants && animate in variants) return variants[animate]
1096
+ if (__DEV__) {
1097
+ console.warn(
1098
+ `[inertia] animate="${animate}" but no matching variant. Did you forget to pass \`variants\`?`,
1099
+ )
1100
+ }
1101
+ return undefined
1102
+ }
1103
+ return animate as AnimateStyle<unknown> | undefined
1104
+ }
1105
+
1106
+ declare const __DEV__: boolean
1107
+
1108
+ /**
1109
+ * Pick the resting/initial-frame value out of an `AnimatableValue`. Plain
1110
+ * numbers and color strings come through unchanged; sequence arrays use their
1111
+ * first element; `{ to }` step objects use `to`. Unresolvable shapes return
1112
+ * `undefined` so the caller can fall back to `DEFAULT_RESTING`.
1113
+ */
1114
+ function restValue(
1115
+ v: AnimatableValue<number | string> | undefined,
1116
+ ): number | string | undefined {
1117
+ if (v === undefined) return undefined
1118
+ if (typeof v === 'number' || typeof v === 'string') return v
1119
+ if (Array.isArray(v)) {
1120
+ return v.length > 0
1121
+ ? restValue(v[0] as AnimatableValue<number | string>)
1122
+ : undefined
1123
+ }
1124
+ if (typeof v === 'object' && v !== null && 'to' in v) {
1125
+ const to = (v as { to: unknown }).to
1126
+ return typeof to === 'number' || typeof to === 'string' ? to : undefined
1127
+ }
1128
+ return undefined
1129
+ }
1130
+
1131
+ /**
1132
+ * Per-layer resolved targets: each declared gesture sub-state collapses to a
1133
+ * map of primitive endpoints (numbers or color strings), already passed
1134
+ * through `targetEndValue` so the worklet can use them directly without
1135
+ * inspecting `AnimatableValue` shapes on the UI thread.
1136
+ */
1137
+ type ResolvedGestureLayers = {
1138
+ pressed?: Record<string, number | string>
1139
+ focused?: Record<string, number | string>
1140
+ focusVisible?: Record<string, number | string>
1141
+ hovered?: Record<string, number | string>
1142
+ }
1143
+
1144
+ function resolveGestureLayers(
1145
+ gesture: GestureSubStates<unknown> | undefined,
1146
+ ): ResolvedGestureLayers | null {
1147
+ if (!gesture) return null
1148
+ const out: ResolvedGestureLayers = {}
1149
+ for (const layer of GESTURE_LAYER_NAMES) {
1150
+ const subState = gesture[layer]
1151
+ if (!subState) continue
1152
+ const resolved: Record<string, number | string> = {}
1153
+ for (const key of ALL_KEYS) {
1154
+ // Shadow offset synthetics decompose from the nested `shadowOffset:
1155
+ // { width, height }` source on the sub-state, the same as on `animate`.
1156
+ if (SHADOW_OFFSET_KEY_SET.has(key)) {
1157
+ const axis = shadowOffsetAxisFor(key as ShadowOffsetKey)
1158
+ const so = (
1159
+ subState as { shadowOffset?: { width?: number; height?: number } }
1160
+ ).shadowOffset
1161
+ const v = shadowOffsetAxisValue(so, axis)
1162
+ if (v !== undefined) resolved[key] = v
1163
+ continue
1164
+ }
1165
+ const raw = (subState as Record<string, unknown>)[key]
1166
+ if (raw === undefined) continue
1167
+ const t = targetEndValue(raw as AnimatableValue<number | string>)
1168
+ if (t !== undefined) resolved[key] = t
1169
+ }
1170
+ out[layer] = resolved
1171
+ }
1172
+ return out
1173
+ }
1174
+
1175
+ /**
1176
+ * Drive a single gesture layer's progress shared value 0↔1 with its own
1177
+ * transition. Resolution priority for the layer config:
1178
+ * `transition.<layerName>` → top-level `transition` → library default spring.
1179
+ * On exit, snap to 0 instantly so the unmount-bound base SV finishes its exit
1180
+ * animation without a stale layer pulling the value off-target.
1181
+ *
1182
+ * The hook is invoked unconditionally (one call per layer) so hook order
1183
+ * stays stable even when `gesture` adds or removes sub-states across renders.
1184
+ */
1185
+ function useGestureLayerProgress<S>(
1186
+ progress: SharedValue<number>,
1187
+ active: boolean,
1188
+ declared: boolean,
1189
+ layer: GestureLayerName,
1190
+ transition: Transition<S> | undefined,
1191
+ isExiting: boolean,
1192
+ shouldReduceMotion: boolean,
1193
+ ): void {
1194
+ const layerCfgSig = stableSig(gestureLayerTransitionFor(layer, transition))
1195
+ useEffect(() => {
1196
+ if (!declared) return
1197
+ if (isExiting) {
1198
+ progress.value = 0
1199
+ return
1200
+ }
1201
+ const target = active ? 1 : 0
1202
+ const cfg = shouldReduceMotion
1203
+ ? ({ type: 'no-animation' } as const)
1204
+ : (gestureLayerTransitionFor(layer, transition) ??
1205
+ ({ type: 'spring' } as const))
1206
+ progress.value = resolveTransition(cfg, target) as never
1207
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1208
+ }, [active, declared, isExiting, shouldReduceMotion, layerCfgSig])
1209
+ }
1210
+
1211
+ type GestureHandlers = Record<string, (event: unknown) => void>
1212
+
1213
+ /**
1214
+ * Build the touch / focus / hover handler props for a gesture-enabled Motion
1215
+ * primitive. Returns an empty object when `gesture` is undefined so the
1216
+ * component renders identically to the gesture-less path (zero overhead).
1217
+ *
1218
+ * Existing user-supplied handlers on the same events are composed: the user's
1219
+ * handler runs first, then the internal state setter. We pull user handlers
1220
+ * out of `rest` rather than overwriting them.
1221
+ */
1222
+ function useGestureHandlers(
1223
+ gesture: GestureSubStates<unknown> | undefined,
1224
+ rest: Record<string, unknown>,
1225
+ setPressed: (next: boolean) => void,
1226
+ setFocused: (next: boolean) => void,
1227
+ setFocusVisible: (next: boolean) => void,
1228
+ setHovered: (next: boolean) => void,
1229
+ ): GestureHandlers {
1230
+ // Deps key on declared-ness, not object identity — a fresh `gesture={...}`
1231
+ // literal each render must not rebuild handlers if the same sub-states are
1232
+ // declared.
1233
+ const hasPressed = gesture?.pressed ? 1 : 0
1234
+ const hasFocused = gesture?.focused ? 1 : 0
1235
+ const hasFocusVisible = gesture?.focusVisible ? 1 : 0
1236
+ const hasHovered = gesture?.hovered ? 1 : 0
1237
+ return useMemo(() => {
1238
+ if (!gesture) return {}
1239
+ const handlers: GestureHandlers = {}
1240
+ if (gesture.pressed) {
1241
+ handlers.onTouchStart = compose(rest.onTouchStart, () => setPressed(true))
1242
+ handlers.onTouchEnd = compose(rest.onTouchEnd, () => setPressed(false))
1243
+ handlers.onTouchCancel = compose(rest.onTouchCancel, () =>
1244
+ setPressed(false),
1245
+ )
1246
+ // Pressable / TouchableOpacity expose press hooks above the touch layer;
1247
+ // forward to those when present so wrapping consumers stay consistent.
1248
+ handlers.onPressIn = compose(rest.onPressIn, () => setPressed(true))
1249
+ handlers.onPressOut = compose(rest.onPressOut, () => setPressed(false))
1250
+ }
1251
+ // Mount onFocus/onBlur if either focus sub-state is declared. The two flags
1252
+ // are independent: `focused` always tracks focus; `focusVisible` only
1253
+ // engages when the most recent input was keyboard (W3C `:focus-visible`
1254
+ // semantics). On native the modality is always `'keyboard'`, so the two
1255
+ // flags move together.
1256
+ if (gesture.focused || gesture.focusVisible) {
1257
+ handlers.onFocus = compose(rest.onFocus, () => {
1258
+ if (gesture.focused) setFocused(true)
1259
+ if (gesture.focusVisible && isFocusVisible()) setFocusVisible(true)
1260
+ })
1261
+ handlers.onBlur = compose(rest.onBlur, () => {
1262
+ if (gesture.focused) setFocused(false)
1263
+ if (gesture.focusVisible) setFocusVisible(false)
1264
+ })
1265
+ }
1266
+ if (gesture.hovered) {
1267
+ // Web-only events. RN-Web 0.72+ accepts these on View; native ignores
1268
+ // them so the cost is zero on iOS / Android.
1269
+ handlers.onMouseEnter = compose(rest.onMouseEnter, () => setHovered(true))
1270
+ handlers.onMouseLeave = compose(rest.onMouseLeave, () =>
1271
+ setHovered(false),
1272
+ )
1273
+ }
1274
+ return handlers
1275
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1276
+ }, [
1277
+ hasPressed,
1278
+ hasFocused,
1279
+ hasFocusVisible,
1280
+ hasHovered,
1281
+ rest.onTouchStart,
1282
+ rest.onTouchEnd,
1283
+ rest.onTouchCancel,
1284
+ rest.onPressIn,
1285
+ rest.onPressOut,
1286
+ rest.onFocus,
1287
+ rest.onBlur,
1288
+ rest.onMouseEnter,
1289
+ rest.onMouseLeave,
1290
+ ])
1291
+ }
1292
+
1293
+ function compose(
1294
+ user: unknown,
1295
+ ours: (event: unknown) => void,
1296
+ ): (event: unknown) => void {
1297
+ if (typeof user !== 'function') return ours
1298
+ return (event: unknown) => {
1299
+ ;(user as (event: unknown) => void)(event)
1300
+ ours(event)
1301
+ }
1302
+ }
1303
+
1304
+ // Suppress the implicit any-return of the rotate ternary's union shape.
1305
+ // `TransformKey` is exported only to keep the type readable in d.ts.
1306
+ export type { TransformKey }