@onlynative/inertia 0.0.1-alpha.7 → 0.0.1-alpha.9

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 (72) hide show
  1. package/README.md +1 -1
  2. package/dist/gestureLayer/index.d.mts +119 -0
  3. package/dist/gestureLayer/index.d.ts +119 -0
  4. package/dist/gestureLayer/index.js +346 -0
  5. package/dist/gestureLayer/index.js.map +1 -0
  6. package/dist/gestureLayer/index.mjs +344 -0
  7. package/dist/gestureLayer/index.mjs.map +1 -0
  8. package/dist/index.d.mts +114 -74
  9. package/dist/index.d.ts +114 -74
  10. package/dist/index.js +388 -1542
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +388 -1545
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/motion/Image.d.mts +1 -1
  15. package/dist/motion/Image.d.ts +1 -1
  16. package/dist/motion/Image.js +244 -1462
  17. package/dist/motion/Image.js.map +1 -1
  18. package/dist/motion/Image.mjs +247 -1465
  19. package/dist/motion/Image.mjs.map +1 -1
  20. package/dist/motion/Pressable.d.mts +1 -1
  21. package/dist/motion/Pressable.d.ts +1 -1
  22. package/dist/motion/Pressable.js +244 -1462
  23. package/dist/motion/Pressable.js.map +1 -1
  24. package/dist/motion/Pressable.mjs +247 -1465
  25. package/dist/motion/Pressable.mjs.map +1 -1
  26. package/dist/motion/ScrollView.d.mts +1 -1
  27. package/dist/motion/ScrollView.d.ts +1 -1
  28. package/dist/motion/ScrollView.js +244 -1462
  29. package/dist/motion/ScrollView.js.map +1 -1
  30. package/dist/motion/ScrollView.mjs +247 -1465
  31. package/dist/motion/ScrollView.mjs.map +1 -1
  32. package/dist/motion/Text.d.mts +1 -1
  33. package/dist/motion/Text.d.ts +1 -1
  34. package/dist/motion/Text.js +244 -1462
  35. package/dist/motion/Text.js.map +1 -1
  36. package/dist/motion/Text.mjs +247 -1465
  37. package/dist/motion/Text.mjs.map +1 -1
  38. package/dist/motion/View.d.mts +1 -1
  39. package/dist/motion/View.d.ts +1 -1
  40. package/dist/motion/View.js +244 -1462
  41. package/dist/motion/View.js.map +1 -1
  42. package/dist/motion/View.mjs +247 -1465
  43. package/dist/motion/View.mjs.map +1 -1
  44. package/dist/touch/index.d.mts +146 -0
  45. package/dist/touch/index.d.ts +146 -0
  46. package/dist/touch/index.js +166 -0
  47. package/dist/touch/index.js.map +1 -0
  48. package/dist/touch/index.mjs +164 -0
  49. package/dist/touch/index.mjs.map +1 -0
  50. package/dist/{types-NmNeJjo1.d.mts → types-cU43dEmH.d.mts} +64 -17
  51. package/dist/{types-NmNeJjo1.d.ts → types-cU43dEmH.d.ts} +64 -17
  52. package/dist/useGesture-B7A_1DVg.d.ts +84 -0
  53. package/dist/useGesture-cimMrzC1.d.mts +84 -0
  54. package/jest-setup.js +4 -0
  55. package/llms.txt +12 -3
  56. package/package.json +22 -2
  57. package/src/__type-tests__/variants.test-d.tsx +67 -0
  58. package/src/gestureLayer/index.ts +21 -0
  59. package/src/gestureLayer/useGestureLayer.ts +285 -0
  60. package/src/index.ts +7 -0
  61. package/src/layout/index.ts +15 -0
  62. package/src/layout/sharedRegistry.ts +111 -0
  63. package/src/layout/useSharedLayout.ts +289 -0
  64. package/src/motion/createMotionComponent.tsx +123 -37
  65. package/src/motion/installCheck.ts +7 -11
  66. package/src/touch/index.ts +18 -0
  67. package/src/touch/useTouchDrag.ts +289 -0
  68. package/src/types.ts +79 -20
  69. package/src/values/index.ts +11 -0
  70. package/src/values/useBooleanSpring.ts +33 -0
  71. package/src/values/useColorTransition.ts +72 -0
  72. package/src/values/useShadow.ts +116 -0
@@ -0,0 +1,289 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ PanResponder,
4
+ type PanResponderGestureState,
5
+ type PanResponderInstance,
6
+ } from 'react-native'
7
+ import {
8
+ useAnimatedStyle,
9
+ useSharedValue,
10
+ type SharedValue,
11
+ } from 'react-native-reanimated'
12
+ import { buildReleaseAnimation } from '../transitions'
13
+ import type { TransitionConfig } from '../types'
14
+
15
+ /**
16
+ * Same drag-result shape as `useDrag` from `@onlynative/inertia-gestures`,
17
+ * minus the `gesture` field (PanResponder spreads handlers, no
18
+ * `<GestureDetector>` wrapper). The shared values + animatedStyle are
19
+ * interchangeable across both hooks; consumers can swap implementations
20
+ * without touching their `useAnimatedStyle` consumers.
21
+ */
22
+ export interface UseTouchDragResult {
23
+ /** Spread onto a `View` / `Pressable` to install the pan responder. */
24
+ panHandlers: PanResponderInstance['panHandlers']
25
+ /** Stable animated `transform` style. */
26
+ animatedStyle: ReturnType<typeof useAnimatedStyle>
27
+ /** Live x translation, persistent across gestures. */
28
+ dragX: SharedValue<number>
29
+ /** Live y translation, persistent across gestures. */
30
+ dragY: SharedValue<number>
31
+ /** True while the gesture is active. */
32
+ isDragging: SharedValue<boolean>
33
+ }
34
+
35
+ /**
36
+ * Release transition shape for PanResponder's JS-thread `onRelease`. Mirrors
37
+ * the gesture-handler adapter's `ReleaseTransition` but with `to` typed as
38
+ * required for spring/timing/no-animation (decay omits it).
39
+ */
40
+ export type TouchReleaseTransition =
41
+ | (TransitionConfig & { type: 'spring'; to: number })
42
+ | (TransitionConfig & { type: 'timing'; to: number })
43
+ | (TransitionConfig & { type: 'decay' })
44
+ | (TransitionConfig & { type: 'no-animation'; to: number })
45
+
46
+ export interface TouchReleaseInfo {
47
+ x: number
48
+ y: number
49
+ velocity: { x: number; y: number }
50
+ }
51
+
52
+ export interface TouchReleaseResult {
53
+ x?: TouchReleaseTransition
54
+ y?: TouchReleaseTransition
55
+ }
56
+
57
+ export interface UseTouchDragOptions {
58
+ /**
59
+ * Restrict the drag to one axis. Defaults to `'both'`. When `'x'` is set
60
+ * the y-axis shared value never updates (and vice versa); velocity is
61
+ * still reported on both for `onDragEnd`.
62
+ */
63
+ axis?: 'x' | 'y' | 'both'
64
+ /**
65
+ * Travel bounds (px from resting). Each side is independently optional.
66
+ * Out-of-bounds values clamp to the limit unless `elastic > 0`.
67
+ */
68
+ constraints?: {
69
+ left?: number
70
+ right?: number
71
+ top?: number
72
+ bottom?: number
73
+ }
74
+ /**
75
+ * Rubber-band coefficient applied to overshoot past `constraints`. `0`
76
+ * (default) hard-clamps; `0.2`-`0.4` is a typical Framer-Motion feel.
77
+ */
78
+ elastic?: number
79
+ /**
80
+ * Fires when the user starts dragging. JS thread.
81
+ */
82
+ onDragStart?: () => void
83
+ /**
84
+ * Fires when the user releases or the gesture terminates. JS thread.
85
+ *
86
+ * Velocity is in px/sec to match the `@onlynative/inertia-gestures` API
87
+ * (PanResponder's native `vx` / `vy` are px/ms; the hook normalizes).
88
+ */
89
+ onDragEnd?: (info: TouchReleaseInfo) => void
90
+ /**
91
+ * Optional release-animation callback. Return per-axis release transitions
92
+ * to animate the SVs to a settled position via Inertia's transition
93
+ * resolver — spring snap-to-tick, decay with bounds, timing settle.
94
+ *
95
+ * Unlike the gesture-handler version, this callback runs on the **JS
96
+ * thread** (PanResponder is JS-only). The returned transitions still drive
97
+ * UI-thread animations via Reanimated — only the decision logic is JS-side.
98
+ */
99
+ onRelease?: (info: TouchReleaseInfo) => TouchReleaseResult | void
100
+ }
101
+
102
+ /**
103
+ * PanResponder-backed drag hook. Pointer-equivalent of `useDrag` from
104
+ * `@onlynative/inertia-gestures`, with two differences:
105
+ *
106
+ * 1. No `react-native-gesture-handler` peer dep required — PanResponder is
107
+ * built into React Native, so this lives in core.
108
+ * 2. Returns `panHandlers` to spread on a `View` / `Pressable` instead of
109
+ * a `gesture` to plug into `<GestureDetector>`.
110
+ *
111
+ * Use this when:
112
+ * - You need keyboard a11y alongside drag (a slider with arrow-key step,
113
+ * a scrollbar with `PageUp` / `PageDown`). PanResponder composes
114
+ * cleanly with `onKeyDown`; gesture-handler doesn't surface keyboard.
115
+ * - You don't want to take `react-native-gesture-handler` as a dependency
116
+ * (smaller bundle, simpler install).
117
+ *
118
+ * Skip this when:
119
+ * - You're already using `react-native-gesture-handler` elsewhere (use
120
+ * `useDrag` from `@onlynative/inertia-gestures` for consistency and
121
+ * better worklet-thread fidelity on release velocity).
122
+ * - You need momentum semantics like the gesture-handler `usePan` —
123
+ * PanResponder's release velocity is JS-thread and slightly less precise.
124
+ *
125
+ * @example
126
+ * ```tsx
127
+ * import { useTouchDrag } from '@onlynative/inertia/touch'
128
+ *
129
+ * function Slider({ ticks }: { ticks: number[] }) {
130
+ * const drag = useTouchDrag({
131
+ * axis: 'x',
132
+ * constraints: { left: 0, right: 280 },
133
+ * onRelease: (e) => {
134
+ * const snap = nearestTick(e.x, ticks)
135
+ * return { x: { type: 'spring', to: snap, velocity: e.velocity.x } }
136
+ * },
137
+ * })
138
+ *
139
+ * return (
140
+ * <Motion.View
141
+ * style={[styles.thumb, drag.animatedStyle]}
142
+ * {...drag.panHandlers}
143
+ * />
144
+ * )
145
+ * }
146
+ * ```
147
+ */
148
+ export function useTouchDrag(
149
+ options: UseTouchDragOptions = {},
150
+ ): UseTouchDragResult {
151
+ const { axis = 'both', constraints, elastic = 0 } = options
152
+
153
+ const dragX = useSharedValue(0)
154
+ const dragY = useSharedValue(0)
155
+ const startX = useSharedValue(0)
156
+ const startY = useSharedValue(0)
157
+ const isDragging = useSharedValue(false)
158
+
159
+ // Snapshot scalars into local consts so the responder callbacks close over
160
+ // primitives, not the `options` literal — a fresh `options` each render
161
+ // would otherwise force the PanResponder identity to change.
162
+ const lockX = axis !== 'y'
163
+ const lockY = axis !== 'x'
164
+ const left = constraints?.left
165
+ const right = constraints?.right
166
+ const top = constraints?.top
167
+ const bottom = constraints?.bottom
168
+ const elasticCoef = elastic
169
+ const { onDragStart, onDragEnd, onRelease } = options
170
+
171
+ const responder = useMemo(
172
+ () => buildResponder(),
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
174
+ [
175
+ lockX,
176
+ lockY,
177
+ left,
178
+ right,
179
+ top,
180
+ bottom,
181
+ elasticCoef,
182
+ onDragStart,
183
+ onDragEnd,
184
+ onRelease,
185
+ ],
186
+ )
187
+
188
+ // Hoisted out of the inline `useMemo` factory to keep the dep list readable
189
+ // and avoid re-declaring closure helpers each render.
190
+ function buildResponder(): PanResponderInstance {
191
+ const handleEnd = (g: PanResponderGestureState) => {
192
+ isDragging.value = false
193
+ const x = dragX.value
194
+ const y = dragY.value
195
+ // PanResponder velocity is px/ms; multiply to match the
196
+ // `@onlynative/inertia-gestures` API (px/sec from gesture-handler).
197
+ const vx = g.vx * 1000
198
+ const vy = g.vy * 1000
199
+ if (onRelease) {
200
+ const result = onRelease({ x, y, velocity: { x: vx, y: vy } })
201
+ if (result) {
202
+ if (result.x && lockX) {
203
+ const toX = 'to' in result.x ? result.x.to : x
204
+ dragX.value = buildReleaseAnimation(
205
+ result.x,
206
+ toX,
207
+ ) as unknown as number
208
+ }
209
+ if (result.y && lockY) {
210
+ const toY = 'to' in result.y ? result.y.to : y
211
+ dragY.value = buildReleaseAnimation(
212
+ result.y,
213
+ toY,
214
+ ) as unknown as number
215
+ }
216
+ }
217
+ }
218
+ if (onDragEnd) onDragEnd({ x, y, velocity: { x: vx, y: vy } })
219
+ }
220
+
221
+ return PanResponder.create({
222
+ // Always claim the start so taps that turn into drags don't slip
223
+ // through to a parent ScrollView. Consumers can compose their own
224
+ // capture predicates by wrapping the returned `panHandlers`.
225
+ onStartShouldSetPanResponder: () => true,
226
+ onMoveShouldSetPanResponder: () => true,
227
+ onPanResponderGrant: () => {
228
+ startX.value = dragX.value
229
+ startY.value = dragY.value
230
+ isDragging.value = true
231
+ if (onDragStart) onDragStart()
232
+ },
233
+ onPanResponderMove: (_e, g) => {
234
+ if (lockX) {
235
+ dragX.value = applyBounds(
236
+ startX.value + g.dx,
237
+ left,
238
+ right,
239
+ elasticCoef,
240
+ )
241
+ }
242
+ if (lockY) {
243
+ dragY.value = applyBounds(
244
+ startY.value + g.dy,
245
+ top,
246
+ bottom,
247
+ elasticCoef,
248
+ )
249
+ }
250
+ },
251
+ onPanResponderRelease: (_e, g) => handleEnd(g),
252
+ onPanResponderTerminate: (_e, g) => handleEnd(g),
253
+ })
254
+ }
255
+
256
+ const animatedStyle = useAnimatedStyle(() => ({
257
+ transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
258
+ }))
259
+
260
+ return {
261
+ panHandlers: responder.panHandlers,
262
+ animatedStyle,
263
+ dragX,
264
+ dragY,
265
+ isDragging,
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot past a
271
+ * bound is scaled by `elastic`, giving a rubber-band feel. `min` / `max`
272
+ * may be `undefined` to leave that side unbounded.
273
+ *
274
+ * JS-thread (PanResponder callbacks are JS, not worklets).
275
+ */
276
+ function applyBounds(
277
+ value: number,
278
+ min: number | undefined,
279
+ max: number | undefined,
280
+ elastic: number,
281
+ ): number {
282
+ if (min !== undefined && value < min) {
283
+ return elastic > 0 ? min + (value - min) * elastic : min
284
+ }
285
+ if (max !== undefined && value > max) {
286
+ return elastic > 0 ? max + (value - max) * elastic : max
287
+ }
288
+ return value
289
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { type ComponentType } from 'react'
1
+ import {
2
+ type ComponentProps,
3
+ type ComponentType,
4
+ type ReactElement,
5
+ type Ref,
6
+ } from 'react'
2
7
  import { type StyleProp } from 'react-native'
3
8
 
4
9
  /**
@@ -239,8 +244,15 @@ export interface VariantController<K extends string = string> {
239
244
 
240
245
  /**
241
246
  * Props injected onto every Motion primitive.
247
+ *
248
+ * The second type parameter `V` is the concrete `variants` map. It is inferred
249
+ * from the `variants` prop at each JSX use (see `MotionComponent`), which is
250
+ * what lets `animate` narrow to the variant key union and reject typos. When
251
+ * no `variants` prop is passed, `V` falls back to `VariantsMap<C>` — whose key
252
+ * type is the open `string`, so `animate` still accepts any string and nothing
253
+ * regresses for the variant-less case.
242
254
  */
243
- export interface MotionProps<C> {
255
+ export interface MotionProps<C, V extends VariantsMap<C> = VariantsMap<C>> {
244
256
  /**
245
257
  * Initial values applied on mount. Read once on mount and intentionally
246
258
  * non-reactive — to reset after a state change, change the component `key`,
@@ -251,10 +263,11 @@ export interface MotionProps<C> {
251
263
  initial?: AnimateStyle<C> | false
252
264
  /**
253
265
  * The animation target. A style object, a variant key (when `variants` is
254
- * supplied), or an array of sequence steps. Variant keys autocomplete when
255
- * `variants` is annotated `as const`.
266
+ * supplied), or an array of sequence steps. When `variants` is set, the
267
+ * string form is narrowed to the map's keys, so a key typo is a compile
268
+ * error and the keys autocomplete — no `as const` required.
256
269
  */
257
- animate?: AnimateStyle<C> | string
270
+ animate?: AnimateStyle<C> | (keyof V & string)
258
271
  /**
259
272
  * Values applied while the component exits via `<Presence>`.
260
273
  */
@@ -263,13 +276,13 @@ export interface MotionProps<C> {
263
276
  * Named animation states. With `variants` set, `animate` accepts a key from
264
277
  * this map.
265
278
  */
266
- variants?: VariantsMap<C>
279
+ variants?: V
267
280
  /**
268
281
  * Imperative controller from `useVariants(...)`. When supplied, `animate`
269
282
  * is read from `controller.current` and re-applied whenever the controller
270
283
  * transitions. `animate` and `controller` should not both be set.
271
284
  */
272
- controller?: VariantController
285
+ controller?: VariantController<keyof V & string>
273
286
  /**
274
287
  * Gesture-driven sub-states (`pressed`, `focused`, `focusVisible`,
275
288
  * `hovered`). When omitted, no handlers are mounted on the underlying
@@ -298,11 +311,34 @@ export interface MotionProps<C> {
298
311
  * — decay is downgraded to spring (no clear target). Reduced motion gates
299
312
  * the prop the same way it gates `animate`.
300
313
  *
301
- * `layoutId` for shared element transitions across screens is deferred:
302
- * Reanimated 4 dropped the underlying `sharedTransitionTag` API and a
303
- * Inertia-side measure-based registry is the in-flight design.
314
+ * `layoutId` (below) is a related but distinct mechanism for shared
315
+ * element transitions across screens `layout` animates this element's
316
+ * own layout changes, `layoutId` animates from a different element's
317
+ * last measured rect to this element's current rect.
304
318
  */
305
319
  layout?: boolean | TransitionConfig
320
+ /**
321
+ * Shared-element transition id. When a Motion primitive with `layoutId`
322
+ * unmounts, its last on-screen rect is recorded under that id; the next
323
+ * mount of any Motion primitive with the same id animates from the
324
+ * recorded rect to its natural position via a FLIP transform stack.
325
+ *
326
+ * Reanimated 4 removed the `sharedTransitionTag` API — `layoutId` is the
327
+ * Inertia-side measure-based replacement. Rects are recorded in
328
+ * parent-relative coordinates (from `onLayout`), which composes when the
329
+ * source and target screens share an outer content container (the common
330
+ * stack-navigator case); nested-parent layouts need the v2
331
+ * window-coordinate path.
332
+ *
333
+ * The same `transition` prop drives the FLIP animation (spring by
334
+ * default; `'timing'` honored; `'decay'` downgrades to spring; reduced
335
+ * motion skips the transition). Out of scope for the first iteration:
336
+ * style-prop interpolation (border radius, colors, etc.) — only the
337
+ * rect-to-rect transform is animated. Two simultaneously-mounted
338
+ * primitives sharing the same `layoutId` are undefined behavior; pick a
339
+ * primitive per id at a time.
340
+ */
341
+ layoutId?: string
306
342
  /**
307
343
  * Fired once per logical animation completion. See `AnimationCallbackInfo`
308
344
  * for the payload shape — transform parents fire once, not per axis.
@@ -311,14 +347,37 @@ export interface MotionProps<C> {
311
347
  }
312
348
 
313
349
  /**
314
- * The component type produced by `createMotionComponent`. Combines the
315
- * underlying component's props (minus `style`, which we replace with an
316
- * animated style) with the Motion-specific props above.
350
+ * Props of a Motion primitive for a given underlying component `C` and a
351
+ * concrete variants map `V`: the component's own props (minus `style`, which
352
+ * we replace with an animated style) intersected with the Motion props.
317
353
  */
318
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
319
- export type MotionComponent<C extends ComponentType<any>> = ComponentType<
320
- Omit<React.ComponentProps<C>, 'style'> &
321
- MotionProps<React.ComponentProps<C>> & {
322
- style?: React.ComponentProps<C>['style']
323
- }
324
- >
354
+ export type MotionComponentProps<
355
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
356
+ C extends ComponentType<any>,
357
+ V extends VariantsMap<ComponentProps<C>> = VariantsMap<ComponentProps<C>>,
358
+ > = Omit<ComponentProps<C>, 'style'> &
359
+ MotionProps<ComponentProps<C>, V> & {
360
+ style?: ComponentProps<C>['style']
361
+ ref?: Ref<unknown>
362
+ }
363
+
364
+ /**
365
+ * The component type produced by `createMotionComponent`.
366
+ *
367
+ * It is a **generic call signature**, not a plain `ComponentType`: the variant
368
+ * map `V` is inferred from the `variants` prop at each JSX use. That inference
369
+ * is what narrows `animate`'s string form to the variant keys, so
370
+ * `<Motion.View variants={{ open, closed }} animate="opne" />` is a compile
371
+ * error and `open` / `closed` autocomplete. With no `variants` prop, `V` falls
372
+ * back to the open `VariantsMap`, so `animate` still accepts any string and the
373
+ * variant-less call site is unchanged.
374
+ */
375
+ export interface MotionComponent<
376
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
377
+ C extends ComponentType<any>,
378
+ > {
379
+ <V extends VariantsMap<ComponentProps<C>> = VariantsMap<ComponentProps<C>>>(
380
+ props: MotionComponentProps<C, V>,
381
+ ): ReactElement | null
382
+ displayName?: string
383
+ }
@@ -1,4 +1,10 @@
1
1
  export { useAnimation } from './useAnimation'
2
+ export { useBooleanSpring } from './useBooleanSpring'
3
+ export {
4
+ useColorTransition,
5
+ type ColorStyleKey,
6
+ type UseColorTransitionOptions,
7
+ } from './useColorTransition'
2
8
  export {
3
9
  useGesture,
4
10
  type UseGestureHandlers,
@@ -12,4 +18,9 @@ export {
12
18
  type UseTransformOptions,
13
19
  } from './useTransform'
14
20
  export { useScroll, type UseScrollResult } from './useScroll'
21
+ export {
22
+ useShadow,
23
+ type ShadowConfig,
24
+ type UseShadowOptions,
25
+ } from './useShadow'
15
26
  export { useVariants } from './useVariants'
@@ -0,0 +1,33 @@
1
+ import { type SharedValue } from 'react-native-reanimated'
2
+ import { useSpring } from './useSpring'
3
+ import { type SpringTransition } from '../types'
4
+
5
+ /**
6
+ * Toggle a 0↔1 progress value with a spring whenever `active` flips.
7
+ *
8
+ * This is the recurring shape behind checkbox checks, accordion expansions,
9
+ * drawer open/closed states, focus rings, and every other binary UI flip
10
+ * that wants spring physics rather than a hard cut. The returned shared
11
+ * value sits at `0` when `active` is `false` and animates toward `1` when
12
+ * `active` flips to `true` (and back again on the reverse flip). Feed it to
13
+ * a `useTransform`, `useShadow`, or a hand-rolled `useAnimatedStyle` to
14
+ * drive whatever the boolean controls visually.
15
+ *
16
+ * ```tsx
17
+ * const progress = useBooleanSpring(isChecked)
18
+ * const indicatorStyle = useAnimatedStyle(() => ({
19
+ * opacity: progress.value,
20
+ * transform: [{ scale: progress.value }],
21
+ * }))
22
+ * ```
23
+ *
24
+ * The spring config follows the same react-spring vocabulary as the rest of
25
+ * the library (`tension` / `friction` / `mass`); omit it to take the
26
+ * library's defaults.
27
+ */
28
+ export function useBooleanSpring(
29
+ active: boolean,
30
+ springConfig?: SpringTransition,
31
+ ): SharedValue<number> {
32
+ return useSpring(active ? 1 : 0, springConfig)
33
+ }
@@ -0,0 +1,72 @@
1
+ import {
2
+ interpolateColor,
3
+ useAnimatedStyle,
4
+ type SharedValue,
5
+ } from 'react-native-reanimated'
6
+
7
+ /**
8
+ * Color style keys understood by React Native that this hook can target.
9
+ * Sticks to the keys that exist on the v0.1 animatable surface so the
10
+ * returned style fragment is always a legal RN style.
11
+ */
12
+ export type ColorStyleKey =
13
+ | 'backgroundColor'
14
+ | 'color'
15
+ | 'borderColor'
16
+ | 'borderTopColor'
17
+ | 'borderRightColor'
18
+ | 'borderBottomColor'
19
+ | 'borderLeftColor'
20
+ | 'tintColor'
21
+ | 'shadowColor'
22
+
23
+ export interface UseColorTransitionOptions {
24
+ /**
25
+ * Which style slot the interpolated color is emitted under. Defaults to
26
+ * `backgroundColor` — the dominant case for state-layer haloes, card
27
+ * fills, and chip surfaces. Override for ring colors (`borderColor`),
28
+ * text colors (`color`), image tints (`tintColor`), etc.
29
+ */
30
+ key?: ColorStyleKey
31
+ }
32
+
33
+ /**
34
+ * Interpolate a single color channel between `from` and `to` as `progress`
35
+ * moves 0→1, returning an animated style fragment that can be spread onto
36
+ * any Reanimated-aware view.
37
+ *
38
+ * ```tsx
39
+ * const progress = useBooleanSpring(isPressed)
40
+ * const fillStyle = useColorTransition(progress, [colors.surface, colors.pressed])
41
+ * const ringStyle = useColorTransition(progress, [colors.outline, colors.primary], {
42
+ * key: 'borderColor',
43
+ * })
44
+ *
45
+ * return <Motion.View style={[styles.chip, fillStyle, ringStyle]} />
46
+ * ```
47
+ *
48
+ * This is a pure interpolator: it does not animate on its own. Drive
49
+ * `progress` upstream with a `useSpring`, `useBooleanSpring`, gesture
50
+ * progress, or scroll-derived `useTransform`. Values outside `[0, 1]`
51
+ * clamp. For a raw `SharedValue<string>` (e.g. to feed a gradient or
52
+ * compose into a hand-rolled `useAnimatedStyle`), use `useTransform`
53
+ * directly with a color output range.
54
+ */
55
+ export function useColorTransition(
56
+ progress: SharedValue<number>,
57
+ range: readonly [string, string],
58
+ options?: UseColorTransitionOptions,
59
+ ): ReturnType<typeof useAnimatedStyle> {
60
+ // Resolve the slot key once on the JS thread so the worklet body
61
+ // consumes a single string literal — consistent with the JS-thread
62
+ // resolver principle that keeps `Object.keys`-style walks off the UI
63
+ // thread (see CLAUDE.md design principle 8).
64
+ const key = options?.key ?? 'backgroundColor'
65
+ const from = range[0]
66
+ const to = range[1]
67
+
68
+ return useAnimatedStyle(() => {
69
+ 'worklet'
70
+ return { [key]: interpolateColor(progress.value, [0, 1], [from, to]) }
71
+ })
72
+ }
@@ -0,0 +1,116 @@
1
+ import {
2
+ interpolate,
3
+ interpolateColor,
4
+ useAnimatedStyle,
5
+ type SharedValue,
6
+ } from 'react-native-reanimated'
7
+
8
+ /**
9
+ * Shape accepted on either end of a `useShadow` tween. Every field is
10
+ * optional — only keys present on at least one side participate in the
11
+ * output style. Mirrors the flat shadow keys on `Motion.View`'s `animate`
12
+ * surface, plus the nested `shadowOffset` source.
13
+ */
14
+ export interface ShadowConfig {
15
+ shadowOpacity?: number
16
+ shadowRadius?: number
17
+ shadowOffset?: { width?: number; height?: number }
18
+ /** Android elevation. iOS shadow consumers can leave this off. */
19
+ elevation?: number
20
+ shadowColor?: string
21
+ }
22
+
23
+ export interface UseShadowOptions {
24
+ /** Shadow state at `progress === 0`. */
25
+ from: ShadowConfig
26
+ /** Shadow state at `progress === 1`. */
27
+ to: ShadowConfig
28
+ /**
29
+ * Driver — typically 0→1. Whatever produces it (a `useSpring`, a gesture
30
+ * progress value, a scroll-derived `useTransform`) is the caller's
31
+ * concern. The hook is a pure interpolator; it does not animate on its
32
+ * own. Values outside `[0, 1]` clamp.
33
+ */
34
+ progress: SharedValue<number>
35
+ }
36
+
37
+ /**
38
+ * Interpolate between two shadow configs as `progress` moves 0→1, returning
39
+ * an animated style fragment that can be spread onto any Reanimated-aware
40
+ * view (including `Motion.*` primitives and a hand-rolled `Animated.View`).
41
+ *
42
+ * ```tsx
43
+ * const progress = useSpring(isElevated ? 1 : 0)
44
+ * const shadowStyle = useShadow({
45
+ * from: { shadowOpacity: 0.08, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1 },
46
+ * to: { shadowOpacity: 0.24, shadowRadius: 12, shadowOffset: { width: 0, height: 8 }, elevation: 8 },
47
+ * progress,
48
+ * })
49
+ *
50
+ * return <Motion.View style={[styles.card, shadowStyle]} />
51
+ * ```
52
+ *
53
+ * Only keys present on either `from` or `to` are emitted. A key present on
54
+ * one side and absent on the other tweens from the present value to the
55
+ * absent side's natural zero (`0` for numbers, `'transparent'` for
56
+ * `shadowColor`, `{ width: 0, height: 0 }` for `shadowOffset`). This is a
57
+ * pure interpolator — to "animate" the shadow, drive `progress` with a
58
+ * spring, timing, or gesture upstream.
59
+ */
60
+ export function useShadow({
61
+ from,
62
+ to,
63
+ progress,
64
+ }: UseShadowOptions): ReturnType<typeof useAnimatedStyle> {
65
+ // Resolve presence + endpoints once on the JS thread so the worklet body
66
+ // consumes flat literals — consistent with the JS-thread resolver
67
+ // principle that keeps `Object.keys`-style walks off the UI thread.
68
+ const hasOpacity =
69
+ from.shadowOpacity !== undefined || to.shadowOpacity !== undefined
70
+ const hasRadius =
71
+ from.shadowRadius !== undefined || to.shadowRadius !== undefined
72
+ const hasElevation =
73
+ from.elevation !== undefined || to.elevation !== undefined
74
+ const hasColor =
75
+ from.shadowColor !== undefined || to.shadowColor !== undefined
76
+ const hasOffset =
77
+ from.shadowOffset !== undefined || to.shadowOffset !== undefined
78
+
79
+ const opacityFrom = from.shadowOpacity ?? 0
80
+ const opacityTo = to.shadowOpacity ?? 0
81
+ const radiusFrom = from.shadowRadius ?? 0
82
+ const radiusTo = to.shadowRadius ?? 0
83
+ const elevationFrom = from.elevation ?? 0
84
+ const elevationTo = to.elevation ?? 0
85
+ const colorFrom = from.shadowColor ?? 'transparent'
86
+ const colorTo = to.shadowColor ?? 'transparent'
87
+ const offsetWFrom = from.shadowOffset?.width ?? 0
88
+ const offsetWTo = to.shadowOffset?.width ?? 0
89
+ const offsetHFrom = from.shadowOffset?.height ?? 0
90
+ const offsetHTo = to.shadowOffset?.height ?? 0
91
+
92
+ return useAnimatedStyle(() => {
93
+ 'worklet'
94
+ const t = progress.value
95
+ const out: Record<string, unknown> = {}
96
+ if (hasOpacity) {
97
+ out.shadowOpacity = interpolate(t, [0, 1], [opacityFrom, opacityTo])
98
+ }
99
+ if (hasRadius) {
100
+ out.shadowRadius = interpolate(t, [0, 1], [radiusFrom, radiusTo])
101
+ }
102
+ if (hasElevation) {
103
+ out.elevation = interpolate(t, [0, 1], [elevationFrom, elevationTo])
104
+ }
105
+ if (hasColor) {
106
+ out.shadowColor = interpolateColor(t, [0, 1], [colorFrom, colorTo])
107
+ }
108
+ if (hasOffset) {
109
+ out.shadowOffset = {
110
+ width: interpolate(t, [0, 1], [offsetWFrom, offsetWTo]),
111
+ height: interpolate(t, [0, 1], [offsetHFrom, offsetHTo]),
112
+ }
113
+ }
114
+ return out
115
+ })
116
+ }