# Inertia — full API reference > This file aggregates every documentation page in source order. Generated from docs/docs/*.md by scripts/build-llms.mjs — do not edit by hand. > Source: https://github.com/onlynative/inertia > Site: https://onlynative.github.io/inertia/ --- Inertia (`@onlynative/inertia`) is a thin, ergonomic wrapper around `react-native-reanimated`. Animations are expressed as props on a component — no shared values, no worklets, no `useAnimatedStyle` boilerplate. The vocabulary takes cues from **Framer Motion** (web) and **react-spring** (cross-platform). > **Status:** `0.0.1-alpha`. The full v0.1 surface is implemented and exercised by the example app — primitives, per-property transitions, sequences, variants, gestures, ``, decay, ``, and the value-layer hooks. APIs may still shift before `0.1.0`; see the [roadmap](https://github.com/onlynative/inertia/blob/main/CLAUDE.md#roadmap) for graduation gates. ## What you get - **DX-first.** Animations are props on a component, not imperative shared values, worklets, and `useAnimatedStyle` boilerplate. - **Per-primitive style inference.** `Motion.View` accepts `ViewStyle` keys, `Motion.Text` accepts `TextStyle`, `Motion.Image` accepts `ImageStyle`. No shared union fallback that lets wrong props slip through. - **react-spring vocabulary.** Spring config uses `tension`, `friction`, `mass`, `velocity`. Reanimated's raw `stiffness` / `damping` never appear in the public surface. - **One `gesture` prop on every primitive.** `pressed`, `focused`, `focusVisible` (keyboard focus only — W3C `:focus-visible`), `hovered` (web) sub-states; no `whileTap` / `whilePress` soup, no separate "pressable" variant. - **Per-primitive tree-shaking.** Subpath imports (`@onlynative/inertia/view`, `/text`, `/image`, `/pressable`, `/scroll-view`) so apps that animate one element don't ship the whole library. - **Stable worklets.** The factory hashes resolved animate / transition objects and memoizes the generated worklet + `useAnimatedStyle` — re-renders with unchanged values produce zero new UI-thread closures. ## Install Pick your package manager — Yarn is the default. The selection syncs across every install snippet in the docs. ```bash yarn add @onlynative/inertia react-native-reanimated ``` Then enable the Reanimated Babel plugin per its [install guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation). ## A first animation ```tsx import { Motion } from '@onlynative/inertia' export function FadeIn() { return ( ) } ``` For tree-shaking when only one primitive is used: ```ts import { MotionView } from '@onlynative/inertia/view' ``` ## Runnable example The full example app is embedded below — every primitive, every gesture, the same code that ships under `example/` in the repo. Tap a screen and interact: Each docs page deep-links into the relevant screen of this same app, so you can try the API as you read. ## Where to next - [Installation](./installation) — install + Reanimated Babel plugin. - [Primitives](./primitives/index.md) — `Motion.View` / `Text` / `Image` / `Pressable` / `ScrollView`. - [Transitions](./transitions) — `spring` (default) / `timing` / `decay` / `no-animation`. - [Sequences & repeat](./sequences) — keyframe arrays and the unified `repeat` shape. - [Variants](./variants) — named animation states + `useVariants` controller. - [Gestures](./gestures) — `pressed` / `focused` / `focusVisible` / `hovered` sub-states. - [Gestures adapter](./gestures-adapter) — drag / pan / swipe via `react-native-gesture-handler` (optional package). - [Gradients](./gradients) — animatable `MotionLinearGradient` via `expo-linear-gradient` (optional package). - [SVG](./svg) — animatable `MotionPath` via `react-native-svg` (optional package). - [Presence](./presence) — mount / unmount transitions. - [Layout](./layout) — auto-layout transitions on position + size changes. - [MotionConfig](./motion-config) — reduce-motion gate. - [Hooks](./api/hooks) and [createMotionComponent](./api/create-motion-component) — escape hatches. --- # Installation Inertia is a thin wrapper around `react-native-reanimated`. The Reanimated install must complete first — Inertia is a peer to it, not a replacement. ## Prerequisites - React Native `>= 0.81` (or Expo SDK `54+`) - React `>= 19` - `react-native-reanimated >= 4.0.0` ## Install Yarn is the default. Switch tabs for npm, pnpm, or Bun — your selection persists across the docs. ```bash yarn add @onlynative/inertia react-native-reanimated ``` Then enable the Reanimated Babel plugin per its [install guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation). The plugin is what transforms `'worklet'`-marked functions into UI-thread code; without it, every animation crashes at runtime. A typical `babel.config.js` for a managed Expo app: ```js module.exports = function (api) { api.cache(true) return { presets: ['babel-preset-expo'], plugins: ['react-native-reanimated/plugin'], } } ``` The Reanimated plugin must be **last** in the plugins array. ## First animation ```tsx import { Motion } from '@onlynative/inertia' export function FadeIn() { return ( ) } ``` ### Try it live The example app runs in the iframe below — tap any screen to interact with the same primitives you'd ship in your app. ## Subpath imports Each primitive is reachable directly so apps that animate one element don't pull in the rest: ```ts import { MotionView } from '@onlynative/inertia/view' import { MotionText } from '@onlynative/inertia/text' import { MotionImage } from '@onlynative/inertia/image' import { MotionPressable } from '@onlynative/inertia/pressable' import { MotionScrollView } from '@onlynative/inertia/scroll-view' ``` `@onlynative/inertia` is `sideEffects: false`, so a bundler with tree-shaking enabled (Metro 0.79+, Webpack, Rollup) will drop primitives you don't reference. The named imports above tree-shake to within ~0.1 kB of the subpath equivalents, verified per-primitive in CI. One caveat: the `Motion` namespace object cannot tree-shake. `import { Motion } from '@onlynative/inertia'` followed by `Motion.View` holds the whole namespace live (it's a literal object — bundlers can't eliminate property accesses). Use it for ergonomics; reach for `import { MotionView } from '@onlynative/inertia'` (or the subpath) when bundle size matters. ## Optional adapter packages Two sibling packages extend Inertia for capabilities that need extra peer dependencies. The core library has no required dependency on either — install only what you need. ### Gestures (`@onlynative/inertia-gestures`) Drag / pan / swipe hooks built on [`react-native-gesture-handler`](https://docs.swmansion.com/react-native-gesture-handler). Compose with any `Motion.*` primitive via ``. ```bash yarn add @onlynative/inertia-gestures react-native-gesture-handler ``` Then follow the [`react-native-gesture-handler` install guide](https://docs.swmansion.com/react-native-gesture-handler/docs/installation) — it needs `` near the root of your app. See [Gestures adapter](./gestures-adapter) for the hooks. ### Gradients (`@onlynative/inertia-gradients`) `MotionLinearGradient` wraps [`expo-linear-gradient`](https://docs.expo.dev/versions/latest/sdk/linear-gradient/) with animatable `colors` / `start` / `end` / `locations`. Works in bare React Native as well as Expo. ```bash yarn add @onlynative/inertia-gradients expo-linear-gradient ``` See [Gradients](./gradients) for the primitive. ### SVG (`@onlynative/inertia-svg`) `MotionPath` wraps [`react-native-svg`](https://github.com/software-mansion/react-native-svg)'s `` with animatable `d` (path morphing on structurally-compatible paths), `fill`, `stroke`, `strokeWidth`, opacities, and `strokeDashoffset`. Works in bare React Native as well as Expo. ```bash yarn add @onlynative/inertia-svg react-native-svg ``` See [SVG](./svg) for the primitive. ## Reduced motion Inertia respects the OS reduce-motion accessibility setting by default — no extra wiring needed. To override the default for the whole app or a subtree, see [MotionConfig](./motion-config). --- # Primitives Every animatable surface is a `Motion.*` component. Each one is an animatable mirror of an underlying React Native primitive — the prop surface (other than `style`) is unchanged, plus the Motion-specific props (`initial`, `animate`, `exit`, `variants`, `controller`, `gesture`, `transition`, `onAnimationEnd`). | Component | Wraps RN's | Style type | | ------------------------------------ | ------------ | ------------ | | [`Motion.View`](./view) | `View` | `ViewStyle` | | [`Motion.Text`](./text) | `Text` | `TextStyle` | | [`Motion.Image`](./image) | `Image` | `ImageStyle` | | [`Motion.Pressable`](./pressable) | `Pressable` | `ViewStyle` | | [`Motion.ScrollView`](./scroll-view) | `ScrollView` | `ViewStyle` | ## Per-primitive style inference `animate`, `initial`, `exit`, and `gesture` sub-states are typed against the underlying component's `style` shape — there is no shared `ViewStyle & TextStyle & ImageStyle` fallback. So `tintColor` autocompletes on `Motion.Image` and is rejected at compile time on `Motion.View`. ```tsx // ✅ // ❌ TS error ``` ## Custom primitives Wrap any component with `createMotionComponent(C)` to get the same prop surface inferred from `C`'s style prop. See [createMotionComponent](../api/create-motion-component). ## Animatable properties (alpha) The alpha supports the properties below across every primitive that accepts them. **Numeric:** `opacity`, `translateX`, `translateY`, `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`, `rotateY`, `width`, `height`, `borderRadius`. Rotation values are degrees as numbers — the runtime wraps with `'deg'` before handing to Reanimated. **Color:** `backgroundColor`, `borderColor`, `color`, `tintColor` (Image only). Any color string Reanimated recognizes works — hex (`'#4f46e5'`, `'#fff'`), `rgb()` / `rgba()`, `hsl()` / `hsla()`, and named colors including `'transparent'`. The target is forwarded straight through `withSpring` / `withTiming`; Reanimated's value setter packs the string to RGBA and interpolates on the UI thread. ```tsx ``` When `initial` is omitted, color slots seed with `'transparent'` — fine for fade-in, but pass an explicit `initial` color when animating between two opaque values to avoid the first frame flashing through transparent. **Optional adapter primitives:** gradient interpolation ships in [`@onlynative/inertia-gradients`](../gradients) (`MotionLinearGradient`), and SVG path morphing ships in [`@onlynative/inertia-svg`](../svg) (`MotionPath`). Both compose with the same `initial` / `animate` / `transition` shape. **Auto-layout transitions:** the [`layout` prop](../layout) animates position + size changes that come from outside the `animate` flow — siblings reordering, dimensions toggling, etc. **Out of scope for alpha:** shared element transitions across screens (`layoutId`). Reanimated 4 dropped the `sharedTransitionTag` API; the Inertia-side measure-based replacement is in design. --- # Motion.View Animatable `View`. The default primitive — use it for boxes, surfaces, and anything that doesn't need to be `Text` / `Image` / scrolling / pressable. ```tsx import { Motion } from '@onlynative/inertia' export function Card() { return ( ) } ``` ## Tree-shaken import ```ts import { MotionView } from '@onlynative/inertia/view' ``` ## Animatable keys `opacity`, `translateX`, `translateY`, `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`, `rotateY`, `width`, `height`, `borderRadius`, `backgroundColor`, `borderColor`. ## Notes - `transform` is composed automatically. Mixing transform keys (e.g. `translateX` + `scale`) into one `animate` object emits a single `transform` array — you don't write `transform: [...]` yourself. - `rotate`, `rotateX`, and `rotateY` are numbers, in degrees. The factory wraps each as `{ rotate: '${value}deg' }` (etc.) for Reanimated. Use `rotateX` / `rotateY` together with a `perspective` style entry to get the 3D effect to render. - `width` / `height` interpolation can jitter on Fabric for non-`flex: 1` containers. Prefer `scaleX` / `scaleY` for resize animations where layout impact is acceptable. --- # Motion.Text Animatable `Text`. `animate` / `initial` / `exit` / `gesture` are typed against `TextStyle`. ```tsx import { Motion } from '@onlynative/inertia' export function Heading({ visible }: { visible: boolean }) { return ( Hello ) } ``` ## Tree-shaken import ```ts import { MotionText } from '@onlynative/inertia/text' ``` ## Animatable keys (alpha) `opacity`, `translateX`, `translateY`, `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`, `rotateY`, `color`. `fontSize` interpolation is deferred — drop to a `useSharedValue` + `useAnimatedStyle` workflow if you need it today. ```tsx Tap me ``` --- # Motion.Image Animatable `Image`. `animate` / `initial` / `exit` / `gesture` are typed against `ImageStyle`, so `tintColor` autocompletes here (and is rejected on other primitives). ```tsx import { Motion } from '@onlynative/inertia' export function Avatar({ source }: { source: { uri: string } }) { return ( ) } ``` ## Tree-shaken import ```ts import { MotionImage } from '@onlynative/inertia/image' ``` ## Animatable keys (alpha) `opacity`, `translateX`, `translateY`, `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`, `rotateY`, `tintColor`. ```tsx ``` --- # Motion.Pressable Animatable `Pressable`. The `gesture.pressed` sub-state hooks directly into Pressable's `onPressIn` / `onPressOut`, picking up touch slop and accessibility semantics from React Native for free. ```tsx import { Motion } from '@onlynative/inertia' export function PrimaryButton({ onPress }: { onPress: () => void }) { return ( ) } ``` ## Tree-shaken import ```ts import { MotionPressable } from '@onlynative/inertia/pressable' ``` ## `style` must be a value, not a function This is the **#1 footgun** for users coming from RN's `Pressable` and the single most important thing to know about `Motion.Pressable`. RN's `Pressable` accepts a function-form `style={(state) => ...}` that re-runs on every press to derive styles from the press/focus/hover state. **`Motion.Pressable` silently drops the function form** — Reanimated's `createAnimatedComponent` wrapper does not invoke it, and you'll see no error. ```tsx // ❌ Silently broken — the function is never called. [ styles.button, pressed && styles.buttonPressed, ]} /> // ✅ Drive press-state through `gesture` instead. ``` This applies to `style` only. Plain values, arrays, and `StyleSheet.create` outputs all work as expected. If you genuinely need conditional styles that aren't animatable through `gesture`, compute them once in render and pass the resulting style array. ## Notes - `gesture` is the only path. There is no separate "Moti-style" pressable variant; this is intentional. - `gesture.focused` works for any focusable Pressable (e.g. with `accessible` / TV / web keyboard focus). Native ignores it gracefully when the platform doesn't fire focus events. See the full [gestures](../gestures) page for sub-state priority and the prop's typing. --- # Motion.ScrollView Animatable `ScrollView`. Animations apply to the scroll **container** itself — useful for entrance transforms, exit fades, or scaling the entire scrollable region. ```tsx import { Motion } from '@onlynative/inertia' export function AnimatedFeed() { return ( {/* rows */} ) } ``` ## Tree-shaken import ```ts import { MotionScrollView } from '@onlynative/inertia/scroll-view' ``` ## Notes - This animates the outer container. To drive animations from scroll position itself (parallax, sticky headers), pair this with [`useScroll`](../api/hooks#usescroll) — it returns `scrollX` / `scrollY` shared values plus an `onScroll` handler you drop on this primitive. - Per-row entrance animations belong on a `Motion.View` row inside the scroll view, not on the scroll view itself. --- # Transitions A `transition` prop decides **how** an `animate` value reaches its target. The default is a spring tuned for everyday UI motion; durations, decay, and instant assignment are all opt-in. ## Top-level vs per-property `transition` accepts either one config (applied to every animating key) or a per-property map. Per-property entries always win. ```tsx // One config, applies to all animating keys // Per-property — opacity uses timing, translateY uses spring ``` ## Types ### `'spring'` (default) react-spring vocabulary. The library converts to Reanimated under the hood — raw `stiffness` / `damping` never appear in the public API. ```tsx transition={{ type: 'spring', tension: 170, // default friction: 26, // default mass: 1, // default velocity: 0, restSpeedThreshold: undefined, restDisplacementThreshold: undefined, delay: 0, repeat: undefined, }} ``` #### Porting from raw Reanimated `stiffness` / `damping` The conversion is a **1:1 alias rename**, not a physics formula. If you have a Reanimated `withSpring` config tuned the way you want, port it by renaming two keys: | Reanimated raw | Inertia | | -------------- | ---------- | | `stiffness` | `tension` | | `damping` | `friction` | | `mass` | `mass` | So a Material Design 3 emphasized spring (`{ stiffness: 380, damping: 32, mass: 1 }`) ports as `{ type: 'spring', tension: 380, friction: 32, mass: 1 }`. No retuning, same perceptual result. ### `'timing'` Duration-based interpolation. Useful for opacity fades and anything where physics feel wrong. ```tsx transition={{ type: 'timing', duration: 250, // default easing: Easing.inOut(Easing.ease), // default delay: 0, repeat: undefined, }} ``` User-supplied `easing` functions are auto-wrapped as worklets at JS time, so plain functions work in nested-transition contexts (variants, sequences, per-property maps) without manual `'worklet'` directives. Easing fns must be pure — no captured JS-thread refs. ### `'decay'` Velocity-driven decay (the gesture-flick model). Combine with `react-native-gesture-handler` to drive scroll-style flick momentum. ```tsx transition={{ type: 'decay', velocity: 800, deceleration: 0.998, clamp: [0, 600], }} ``` `decay` cannot be repeated — `repeat` is ignored on decay configs. ### `'no-animation'` Skip interpolation entirely; jump to the target value. Equivalent to a direct shared-value assignment, but stays inside the transition shape so per-property overrides compose: ```tsx transition={{ opacity: { type: 'timing', duration: 200 }, translateY: { type: 'no-animation' }, }} ``` This is also what reduced-motion mode swaps every transition into. See [MotionConfig](./motion-config). ## `delay` Available on every type except `'no-animation'`. Applied before the underlying animation runs: ```tsx transition={{ type: 'spring', delay: 200 }} ``` ## `repeat` A unified shape — one prop, no flags soup. See [sequences and repeat](./sequences#repeat) for the full table. ## Defaults | Type | Field | Default | | ------ | ---------- | --------------------------- | | spring | `tension` | `170` | | spring | `friction` | `26` | | spring | `mass` | `1` | | timing | `duration` | `250` | | timing | `easing` | `Easing.inOut(Easing.ease)` | If those defaults move, the source of truth is `packages/core/src/transitions/resolve.ts`. --- # Sequences and repeat Per-property keyframes and looping live in two related shapes: arrays for sequence steps, and the unified `repeat` config on transitions. ## Keyframes Pass an array as the value of any animatable key to step through frames in order: ```tsx ``` Each step inherits the transition that's in effect for that key. Sequences run on the UI thread without bouncing through JS. ## Per-step transitions Replace any step with `{ to, ...transitionOverride }` to tune that step's physics independently: ```tsx ``` We use `to` (not `value`) because steps describe a destination — "animate **to** this". ## Repeat `repeat` lives on the transition, not on `animate`. One shape, no `loop` / `repeatReverse` cousins. | Form | Meaning | | ------------------------------------------ | ---------------------------------------------- | | `repeat: 3` | Run 3 iterations total, alternating direction. | | `repeat: 'infinite'` | Loop forever, alternating direction. | | `repeat: { count: 3, alternate: false }` | Full control. `alternate` defaults to `true`. | | `repeat: { count: 'infinite', alternate }` | Endless with explicit alternate flag. | ```tsx ``` When applied to a sequence, repeat wraps the **whole sequence**, not each step. Per-step `repeat` overrides remain step-local — they apply only inside that step. `'no-animation'` and `'decay'` configs ignore `repeat`. ## `onAnimationEnd` Sequence and repeat lifecycle is reported through one callback: ```tsx { // phase: 'step' | 'sequence' | 'repeat' | 'animation' // step: index in the keyframe array, undefined for non-sequences // iteration: 0 on the first pass, 1 on the second, … }} /> ``` | `phase` | When it fires | | ------------- | --------------------------------------------------------------------- | | `'step'` | A non-final step in a sequence settles. | | `'sequence'` | Last step of a non-final iteration — the sequence is about to repeat. | | `'repeat'` | A non-final iteration of a non-sequence animation completes. | | `'animation'` | The terminal phase of the property — no more passes will run. | Transform parents fire once per logical event, not once per axis. A `translateX` + `translateY` animation on the same primitive produces the callback shape you'd expect from "translate", not two duplicates — when the terminal `'animation'` phase fires for any transform key, `key` is reported as the sentinel `'transform'`. Mid-step events (`'step'` / `'sequence'` / `'repeat'`) still fire per-axis with the specific axis name (`'translateX'`, `'scale'`, etc.) since each step is its own logical event. --- # Variants Named animation states. Define them once, then drive transitions by passing a key — no extra hook required for the common case. ## Declarative ```tsx import { Motion } from '@onlynative/inertia' const variants = { closed: { translateY: 100, opacity: 0 }, open: { translateY: 0, opacity: 1 }, } as const export function Sheet({ isOpen }: { isOpen: boolean }) { return ( ) } ``` Annotate the variants map with `as const` so variant keys autocomplete on `animate`. ## Programmatic — `useVariants` For chaining, async transitions, or driving variants from non-React code (event handlers, gesture callbacks, network responses), build a controller with `useVariants` and pass it through the `controller` prop: ```tsx import { Motion, useVariants } from '@onlynative/inertia' const variants = { resting: { scale: 1, opacity: 1 }, loading: { scale: 0.96, opacity: 0.6 }, done: { scale: 1.04, opacity: 1 }, } as const export function SaveButton() { const controller = useVariants(variants, 'resting') async function save() { controller.transitionTo('loading') await api.save() controller.transitionTo('done') } return ( ) } ``` `useVariants(variants, initial?)` returns a `{ current, transitionTo, subscribe }` controller. `current` is the active key; `transitionTo(next)` re-applies the matching variant on every subscribed Motion primitive. When both `controller` and `animate` are set on the same primitive, the controller wins. Don't mix them — the typed contract is "either drive imperatively or declaratively, not both". ## Variant transitions `transition` resolves from the Motion primitive, not from each variant. A single transition on the wrapping primitive applies to every variant target: ```tsx ``` Per-variant transitions land in v0.2; today, switch the wrapping primitive's `transition` based on `current` if you need it. --- # Gestures A single `gesture` prop on every Motion primitive — no `whileTap` / `whilePress` soup, no separate "pressable" variant. When the prop is omitted no handlers are mounted (zero overhead). ```tsx ``` ## Sub-states | Sub-state | Active when | Backed by | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `pressed` | A finger / pointer is on the component (touch start → end / cancel). | `onTouchStart` / `End` / `Cancel`, plus `onPressIn` / `onPressOut` for `Motion.Pressable`. | | `focused` | The component owns focus, regardless of how it arrived (mouse, touch, or keyboard). | `onFocus` / `onBlur`. | | `focusVisible` | Focus arrived from the keyboard (W3C `:focus-visible`). On native — where focus always arrives via D-pad / hardware keyboard / screen reader — behaves identically to `focused`. | `onFocus` + module-level input-modality tracker (web `keydown` vs `pointerdown` / `mousedown` / `touchstart`). | | `hovered` | Pointer is over the component. **Web-only**, no-op on native. | `onMouseEnter` / `onMouseLeave`. | Sub-states layer over the base `animate` target per-property. When a sub-state is released, the property animates back to whatever was set in `animate` (or to the property's default resting value if `animate` doesn't touch it). Use `focused` for state-layer fills (any focus, including click-focus on web) and `focusVisible` for focus rings (keyboard-only). Declaring both gives you the right behaviour automatically: clicking a button shows the state layer; tabbing to it shows the state layer **and** the ring. ## Priority When multiple sub-states are active at once, they layer **additively** in this order — later layers composite over earlier ones: `hovered` → `focused` → `focusVisible` → `pressed` Each declared sub-state owns its own progress (0↔1) shared value that fades in when the sub-state activates and back out when it releases. The `useAnimatedStyle` worklet composites the layers per-property: ``` v = base v = lerp(v, hovered.value, progressHovered) // if declared v = lerp(v, focused.value, progressFocused) // if declared v = lerp(v, focusVisible.value, progressFocusVisible) // if declared v = lerp(v, pressed.value, progressPressed) // if declared ``` (Color-valued keys use `interpolateColor` instead of `lerp`.) When a single sub-state is active, this collapses to "highest-priority declared layer wins" — a `pressed` target overrides everything below it. The win of layered composition is in **overlapping transitions**: release-while-still-hovered fades the press layer back to 0 independently while the hover layer holds at 1, so the value lands on the hover target rather than snapping back to base. ## Per-layer transitions Each layer animates with its own transition. Resolution priority: 1. `transition.` on the parent primitive (e.g. `transition.pressed`) 2. The top-level `transition` (when written as a top-level transition object) 3. Library default (spring) ```tsx ``` Per-layer entries (`pressed`, `hovered`, …) and per-property entries (`backgroundColor`, `opacity`, …) live on the same `transition` map and don't conflict — none of the gesture-layer names are valid style props. ## Type inference `gesture` sub-states are typed against the same `style`-derived shape as `animate`. So `tintColor` autocompletes inside `gesture.pressed` on `Motion.Image` and is rejected on `Motion.View`. ## Composing user handlers Inertia composes its internal handlers with whatever you've already attached: ```tsx analytics.track('press', event)} gesture={{ pressed: { scale: 0.96 } }} /> ``` Your `onPressIn` runs first, then the internal pressed-state setter. The same composition applies to every event the gesture prop subscribes to. ## When the prop isn't enough — `useGesture` The `gesture` prop animates the receiver's own style. If you need one Pressable's gesture state to drive **multiple** animated views (a focus ring rendered as a sibling, an MD3 state-layer halo over the content, a separate icon-color animation), reach for [`useGesture`](./api/hooks#usegesturetransition) — the hook-form of this prop. It returns the underlying 0↔1 progress shared values for `pressed` / `focused` / `focusVisible` / `hovered` plus a handler bag to spread on a `Pressable`. Feed the shared values into as many `useAnimatedStyle` blocks as you need. ```tsx const { pressed, focused, hovered, handlers } = useGesture() // ...drive a focus ring, a halo, a tint — each from the same gesture state {children} ``` The prop and the hook share the layered-blend model, the `isFocusVisible()` semantics, and the `` gating — they're the same machinery, two surfaces. ## When you need drag, pan, or swipe The `gesture` prop covers Pressable-shaped states — anything that boils down to "active / inactive / focused / hovered". For continuous, value-bearing gestures (a thumb that follows the finger, a sheet that flicks closed, a carousel with momentum), reach for the [gestures adapter](./gestures-adapter): `useDrag`, `usePan`, `useSwipe`. It's an opt-in sibling package so the core library doesn't ship a `react-native-gesture-handler` peer for apps that only animate buttons. A fully gesture-driven `Slider` is the canonical example the core package can't build alone — the thumb's position has to track touch X continuously, and that's what the adapter is for. --- # Gestures adapter `@onlynative/inertia-gestures` adds drag / swipe / pan hooks built on `react-native-gesture-handler`. It is an **optional** sibling package — install it only if you need richer-than-`pressable` gestures. The core library has no required `gesture-handler` dependency. The hooks compose with any `Motion.*` primitive via a `` and a single `style` slot — they don't replace the `gesture` prop, they extend what's reachable beside it. ## Install ```bash yarn add @onlynative/inertia-gestures react-native-gesture-handler ``` Then follow the [`react-native-gesture-handler` install guide](https://docs.swmansion.com/react-native-gesture-handler/docs/installation) — it needs `` near the root of your app. ## `useDrag` Drag a Motion primitive on one or two axes, with optional bounds and rubber-band overshoot. ```tsx import { GestureDetector } from 'react-native-gesture-handler' import { Motion } from '@onlynative/inertia' import { useDrag } from '@onlynative/inertia-gestures' function DraggableBox() { const drag = useDrag({ axis: 'both', constraints: { left: -100, right: 100, top: -60, bottom: 60 }, elastic: 0.4, }) return ( ) } ``` | Option | Type | Default | Notes | | ------------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | | `axis` | `'x' \| 'y' \| 'both'` | `'both'` | Lock the gesture to one axis. | | `constraints` | `{ left?, right?, top?, bottom? }` | none | Bounds in px from the resting position. Each side independently optional. | | `elastic` | `number` (0–1) | `0` | Rubber-band coefficient past `constraints`. `0` hard-clamps; `0.4` is a typical Framer-Motion feel. | | `onDragStart` | `() => void` | none | Fires on JS thread when drag begins. | | `onDragEnd` | `(info: { x, y, velocity: { x, y } }) => void` | none | Fires on JS thread when drag ends. | Returns `{ gesture, animatedStyle, dragX, dragY, isDragging }`. The shared values are exposed for power use cases (deriving secondary effects from drag position). ## `useSwipe` Directional commit-or-snap-back gesture. Tracks live translation while the user drags; on release, fires `onSwipe` if either the distance or velocity threshold is met along an allowed axis. Whether or not it commits, the position springs back to zero — the consumer drives the side effect (delete, dismiss, advance). ```tsx const swipe = useSwipe({ directions: ['left', 'right'], distanceThreshold: 100, onSwipe: (direction) => { if (direction === 'left') dismiss() if (direction === 'right') accept() }, }) return ( {children} ) ``` | Option | Type | Default | Notes | | ------------------- | --------------------------------------------- | -------- | ----------------------------------------------------------------- | | `directions` | `Array<'left' \| 'right' \| 'up' \| 'down'>` | all four | Only commits along listed directions. Off-axis swipes do nothing. | | `distanceThreshold` | `number` (px) | `80` | Distance past which a release commits. | | `velocityThreshold` | `number` (px/sec) | `800` | Flick velocity that commits even before distance threshold. | | `onSwipe` | `(direction, { distance, velocity }) => void` | none | JS-thread callback. Direction is one of the allowed values. | The dominant axis (whichever of \|tx\|, \|ty\| is larger at release) decides which direction is checked. ## `usePan` Camera-style pan with momentum on release. Position **persists across separate gestures** — the next pan starts from the current offset, not zero — and on release the position continues gliding via Reanimated's `withDecay`. ```tsx const pan = usePan({ constraints: { left: -240, right: 240, top: -240, bottom: 240 }, deceleration: 0.997, }) return ( {/* large content */} ) ``` | Option | Type | Default | Notes | | ----------------- | ---------------------------------- | ------------------ | ----------------------------------------------------------------------------- | | `constraints` | `{ left?, right?, top?, bottom? }` | none | Hard clamp during gesture and during decay. | | `deceleration` | `number` | Reanimated default | Higher = momentum dies faster. Roughly `0.99` (slow) to `0.999` (long glide). | | `disableMomentum` | `boolean` | `false` | Hard stop on release (drag-like). | For rubber-banded constraints, prefer `useDrag({ elastic })` — `usePan` hard-clamps because it composes with `withDecay`'s `clamp` parameter, which doesn't have an overshoot mode. ## When to pick which | Gesture | Snap back on release? | Momentum on release? | Typical UX | | ---------- | --------------------- | ---------------------------- | -------------------------------------------------- | | `useDrag` | No — stays where left | No | Move-to-position, sliders, sortable handles. | | `useSwipe` | Yes — always | No (springs back) | Swipe-to-delete rows, card stacks, dismiss sheets. | | `usePan` | No — stays where left | Yes — coasts via `withDecay` | Maps, zoomable canvases, large-image navigation. | ## Try it ## Why a separate package? The core library's `gesture` prop covers `pressed` / `focused` / `focusVisible` / `hovered` — boolean state-overlay gestures with no continuous translation. Drag, swipe, and pan are **value-driven**: the gesture publishes a continuous position that the consumer composes into the view's style. They don't fit the boolean-state model, and they need `react-native-gesture-handler` (which we don't want as a required peer of core). Splitting them off keeps core's surface area minimal and lets the gesture-handler dependency stay opt-in. The hooks return primitives (`gesture`, `animatedStyle`, shared values) rather than wrapping Motion in a new component, so they layer cleanly on the existing `Motion.*` set. --- # Gradients `@onlynative/inertia-gradients` adds an animatable linear gradient built on `expo-linear-gradient`. It is an **optional** sibling package — install it only when you need to animate gradient stops. The core library has no required `expo-linear-gradient` dependency. `MotionLinearGradient` accepts the same `initial` / `animate` / `transition` shape as the core `Motion.*` primitives, with animatable keys for `colors`, `start`, `end`, and `locations`. ## Install ```bash yarn add @onlynative/inertia-gradients expo-linear-gradient ``` `expo-linear-gradient` works in bare React Native projects as well as Expo — no `expo-modules-core` runtime is required. ## Usage ```tsx import { MotionLinearGradient } from '@onlynative/inertia-gradients' function Hero() { return ( ) } ``` The static `colors` prop is required — it sets the visual on first render and **locks the slot count** for the lifetime of the component. To resize the gradient (e.g. swap a 2-stop for a 3-stop), remount via `key={...}`. The component throws in dev if `colors.length` changes between renders. ## Animatable props | Key | Shape | Notes | | ----------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `colors` | `readonly string[]` | Element-wise color interpolation via Reanimated's color setter. Length must match the static `colors` prop. | | `start` | `{ x: number, y: number }` | Normalized `[0, 1]` coordinates. `x` and `y` animate independently — useful for rotating gradient direction. | | `end` | `{ x: number, y: number }` | Same shape as `start`. | | `locations` | `readonly number[]` | Optional stop positions. If supplied at mount, must remain supplied and same-length as `colors` for the component's lifetime. | Per-property transitions work just like the core primitives: ```tsx ``` ## `initial` Pass `initial` to override the mount-frame values (so the component starts somewhere other than the static props), or `initial={false}` to start at the `animate` target with no mount animation. ```tsx ``` ## Reduced motion `MotionLinearGradient` participates in [``](./motion-config.md) the same way the core primitives do — when the OS reduce-motion setting is on (or you pass `reducedMotion="always"`), transitions resolve as direct assignment instead of `withSpring` / `withTiming`. ## What this primitive doesn't do (v0.2) - **Radial / conic gradients** — linear-only for v0.2. Radial lands in v0.3 once the linear API is validated. - **Slot-count resize** — the colors array length is locked at mount. To change it, remount via `key={...}`. - **Per-stop sequence keyframes** — `animate.colors` accepts a single target array, not a nested array of arrays. For chained gradient transitions, drive the target through state and let React re-render. --- # SVG `@onlynative/inertia-svg` adds animatable SVG primitives built on [`react-native-svg`](https://github.com/software-mansion/react-native-svg). It is an **optional** sibling package — install it only when you need to morph paths or animate `fill` / `stroke`. The core library has no required `react-native-svg` dependency. `MotionPath` wraps `` and accepts the same `initial` / `animate` / `transition` shape as the core `Motion.*` primitives, with animatable keys for the path data (`d`) plus color and numeric paint properties. ## Install ```bash yarn add @onlynative/inertia-svg react-native-svg ``` `react-native-svg` works in bare React Native projects as well as Expo. ## Usage ```tsx import Svg from 'react-native-svg' import { MotionPath } from '@onlynative/inertia-svg' function Toggle({ open }) { return ( ) } ``` The static `d` prop is required. Its **command sequence is locked at first render** — every target `d` you pass via `animate` or `initial` must produce the same command letters in the same order after implicit-repeat expansion. Element-wise scalar interpolation is the entire morphing model. ## Structural compatibility Two paths are morphable iff their normalized template (command letters, in order, with case preserved) is equal. Examples: ``` ✅ M 0 0 L 10 10 Z ↔ M 50 50 L 80 80 Z same: M L Z ✅ M 0 0 L 10 10 L 20 20 ↔ M 0 0 50 50 60 60 same: M L L (implicit repeats after M expand to L) ❌ M 0 0 L 10 10 Z ↔ M 0 0 L 10 10 differs: count ❌ M 0 0 L 10 10 ↔ M 0 0 l 10 10 differs: absolute L vs relative l ❌ M 0 0 L 10 10 ↔ M 0 0 C 1 1 2 2 3 3 differs: L vs C ``` The component throws in dev when the templates diverge — either at mount (if `initial.d` mismatches the static `d`), when `animate.d` changes shape, or when the static `d` prop itself changes shape between renders. In production those throws degrade to a no-op snap so a single bad target doesn't crash the screen, but you should treat dev errors as bugs. To switch between structurally different shapes, **remount with a new `key`**: ```tsx ``` Path normalization that resamples between arbitrary shapes (the flubber-style approach) is out of scope for v0.2. ## Animatable props | Key | Shape | Notes | | ------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------- | | `d` | `string` | Path data. Source and target must share the same template — see above. Each scalar param springs / times independently. | | `fill` | `string` | Color, interpolated via Reanimated's color setter. Defaults to `'transparent'` if neither static nor `initial` is supplied. | | `stroke` | `string` | Same as `fill`. | | `strokeWidth` | `number` | Numeric. Default seed `1`. | | `strokeOpacity` | `number` | Numeric, 0–1. | | `fillOpacity` | `number` | Numeric, 0–1. | | `opacity` | `number` | Numeric, 0–1. | | `strokeDashoffset` | `number` | Useful for "draw" animations on a dashed stroke. | Per-property transitions work just like the core primitives: ```tsx ``` ## `initial` Pass `initial` to override the mount-frame values (so the component starts somewhere other than the static props), or `initial={false}` to start at the `animate` target with no mount animation. ```tsx ``` If `initial.d` is provided, it must be template-compatible with the static `d` — same rule as `animate.d`. ## Reduced motion `MotionPath` participates in [``](./motion-config.md) the same way the core primitives do — when the OS reduce-motion setting is on (or you pass `reducedMotion="always"`), transitions resolve as direct assignment instead of `withSpring` / `withTiming`. ## What this primitive doesn't do (v0.2) - **Path resampling** between structurally different shapes. Same-template morphs only — the rest is your `key` to remount. - **Other SVG shapes** (`Circle`, `Rect`, `Line`, `Ellipse`). They land in a follow-up release; pencil them in if you need them. - **Gradient fills inside the SVG**. For gradient fills use `` + `` from `react-native-svg` and animate the stops via [`MotionLinearGradient`](./gradients.mdx)'s patterns; the path itself just references the gradient by `url(#id)`. - **Path command interpolation** (e.g. morphing an `L` into a `C`). Element-wise scalar interpolation is intentional — it's predictable, cheap, and matches what designers reach for 95% of the time. ## Path utilities The tokenizer and template helpers are exported for downstream tooling: ```ts import { parsePathD, templateOf, diffTemplate, flattenParams, serializePath, } from '@onlynative/inertia-svg' const segs = parsePathD('M 0 0 L 10 10 Z') // [{ cmd: 'M', args: [0, 0] }, …] const t = templateOf(segs) // { cmds: ['M','L','Z'], widths: [2,2,0], size: 4 } const params = flattenParams(segs) // [0, 0, 10, 10] const out = serializePath(t, [5, 5, 20, 20]) // 'M 5 5L 20 20Z' const err = diffTemplate(t, templateOf(parsePathD('M 0 0 L 1 1'))) // → 'command count differs: …' ``` `serializePath` is a worklet — call it inside `useAnimatedProps` if you build your own SVG primitives on top of this layer. --- # Presence `` keeps a child mounted long enough to play its `exit` animation when it's removed from the tree. Half the noise of ``, same role. ```tsx import { Motion, Presence } from '@onlynative/inertia' export function Toast({ visible }: { visible: boolean }) { return ( {visible ? ( ) : null} ) } ``` ## How it works `Presence` snapshots children that disappear from its `children` array, holds them in place, and waits for each to finish its exit animation before unmounting. It uses Reanimated's `entering` / `exiting` lifecycle for the underlying frames. ## Required: `key` Every direct child of `` must have an explicit `key`. Without one, React falls back to positional identity and removal looks like a prop change — nothing to mark exiting. In dev, keyless children produce a warning and are skipped. ## Tap-deaf exits The moment a child starts its exit animation, Inertia merges `pointerEvents: 'none'` onto its style. Taps fall through to whatever's underneath instead of re-triggering an about-to-unmount node. This is the "two clicks to re-open the popover" bug from the prior art — by design, you only need one. ## Re-entry interrupts exit If a key reappears in `children` while it was exiting, the in-flight exit animation interrupts back toward the `animate` values. The component instance persists across the round trip — no remount, no flicker. ## Multiple children `Presence` handles lists too: ```tsx {items.map((item) => ( ))} ``` Each child's exit timing is independent — one row can finish exiting while another is still animating in. ## What can be a child? Any component that consumes `usePresence()` and calls `safeToRemove()` when its exit completes. Every `Motion.*` primitive does this. Plain `View` / `Text` will linger in the snapshot once removed because nothing tells `Presence` they're done — pick a Motion primitive instead. ## Accessing presence state Custom components can read presence state directly: ```tsx import { usePresence } from '@onlynative/inertia' function MyExitable() { const presence = usePresence() // presence is null when no ancestor exists // presence?.isPresent flips false when the parent removes us // presence?.safeToRemove() once the exit animation finishes } ``` --- # Layout The `layout` prop animates position and size changes that come from outside the `animate` flow — a flex sibling growing, a list reordering, a column toggling its width. Without it, those changes snap; with it, they interpolate. ```tsx import { Motion } from '@onlynative/inertia' function ReorderableRow({ item, onPress }: Props) { return ( ) } ``` Internally the prop resolves to Reanimated's `LinearTransition` builder; the same react-spring vocabulary (`tension`, `friction`, `mass`) you use for `transition` works here too. Raw Reanimated names (`stiffness` / `damping`) never appear on the public API. ## Accepted shapes | Value | Meaning | | ------------------ | ----------------------------------------------------------------------------------------------- | | omitted / `false` | No layout animation. Position and size changes snap (default). | | `true` | Library default spring (`tension: 170`, `friction: 26`, `mass: 1`). | | `TransitionConfig` | `'spring'` or `'timing'`. `'decay'` downgrades to spring; `'no-animation'` skips the animation. | ```tsx // default spring // custom spring // duration-based t * t }} /> // custom easing ``` User-supplied easing functions are auto-wrapped with the `'worklet'` directive at JS time, same as `transition.easing` — you don't need to remember the worklet boundary. ## What triggers a layout animation `LinearTransition` fires whenever the underlying native view's measured frame changes between commits. The common triggers: - The component's siblings reorder in a flex container. - The component's size changes because its `style` props swap (`height: 56` ↔ `height: 96`). - The component's position shifts because a sibling grew, shrank, or was inserted. The `animate` flow is independent. A `Motion.View` can have both `animate={{ opacity }}` and `layout` — the opacity drives through `useAnimatedStyle`, the layout drives through Reanimated's native commit hook. They don't fight. ## Reduced motion `layout` participates in [``](./motion-config.md) — when reduced motion is active, the prop resolves to no builder and changes snap. We pass `undefined` to the underlying component rather than a `.duration(0)` builder because Reanimated still runs commit-tracking machinery in the latter case; the snap path is genuinely cheaper. ## What this prop doesn't do (yet) - **Shared element transitions across screens (`layoutId`)** — Reanimated 4 dropped the `sharedTransitionTag` API the previous design relied on. A measure-based Inertia-side registry is the in-flight replacement, but it ships separately. - **Per-axis control (`layout="position"` / `layout="size"`)** — `LinearTransition` doesn't expose an axis filter; the whole frame animates together. If you need to gate a specific dimension, animate it through `animate` instead. - **Layout-tied callbacks** — `onAnimationEnd` fires for `animate` keys, not for layout commits. Reanimated's `withCallback` is what backs that on the layout side; we haven't surfaced it yet. ## Caveats - The wrapped component must render a native host view. Every `Motion.*` primitive does; if you wrap a custom component via `createMotionComponent(C)`, ensure `C` ultimately renders a host view, or the prop is a no-op. - Layout animations on virtualized list items (FlatList rows) can fight the list's own measurement passes — measure twice before adding `layout` to row components. The [Perf bench](./perf-bench.md) screen is the place to test. --- # MotionConfig A provider that gates how descendant Motion primitives respond to the OS reduce-motion accessibility setting. :::tip Free accessibility win for migrators Apps moving from hand-rolled `useSharedValue` + `useAnimatedStyle` to Inertia primitives pick up reduce-motion compliance automatically — every `Motion.*` component subscribes to the OS setting via [`useShouldReduceMotion()`](#reading-the-resolved-value) without any per-component plumbing. If you previously had no reduce-motion handling, you have it now. ::: ## Default — respect the OS By default (and without any provider in the tree), Inertia respects the OS reduce-motion setting. When the user enables it, every per-key transition is swapped for `'no-animation'` — values snap to their target instantly. Sequences still iterate, but each step settles immediately. ```tsx import { MotionConfig } from '@onlynative/inertia' export function App() { return {/* Your app */} } ``` `reducedMotion="user"` is the default value for the prop, so wrapping the root explicitly is mostly a documentation gesture — it's already what happens without a provider. ## Modes | Value | Behavior | | ---------- | -------------------------------------------------------------------------------------------- | | `'user'` | Defer to the OS accessibility setting. The right default for app-level wrappers. | | `'never'` | Animate regardless of the OS setting. Use sparingly — e.g. essential onboarding transitions. | | `'always'` | Never animate, regardless of OS setting. Useful for tests and snapshots. | ## Scope `` is just a context provider. Wrap a subtree to override behavior locally: ```tsx ``` ## Reading the resolved value Two hooks are available for components that want to react to the active mode: ```tsx import { useMotionConfig, useShouldReduceMotion } from '@onlynative/inertia' function MyComponent() { const { reducedMotion } = useMotionConfig() const reduce = useShouldReduceMotion() // reduce: boolean — already accounts for the OS setting when mode is 'user' } ``` `useShouldReduceMotion()` is what every Motion primitive uses internally. It subscribes to OS changes via Reanimated's `useReducedMotion()`, so toggling the accessibility setting at runtime re-renders subscribed primitives. --- # Perf bench A manual harness for the Phase-3 acceptance bar: > A virtualized-list row using `Motion.Pressable` with a `gesture` prop matches a hand-rolled `Pressable + useAnimatedStyle` row within 5% on Android dropped-frames (the moti #322 / #336 bar). The example app's **Perf bench** screen renders 1000 list rows with a press-state scale animation. A toggle flips the row implementation between Inertia (`Motion.Pressable` + `gesture`) and a hand-rolled equivalent (`Pressable` + `useSharedValue` + `useAnimatedStyle` + `withSpring`). The spring physics are byte-identical so the only difference is which library drives the shared value. The harness uses React Native's built-in `FlatList` so it runs in Expo Go without a custom dev client. For the canonical moti #322 / #336 reproduction (the issues were against FlashList specifically), swap `FlatList` → `@shopify/flash-list`'s `FlashList` in [example/screens/PerfBenchScreen.tsx](https://github.com/onlynative/inertia/blob/main/example/screens/PerfBenchScreen.tsx) and run a custom dev client (`pnpm --filter @onlynative/inertia-example android`). Same row code; the list-virtualization tax is held constant across both row variants either way. ## What "within 5%" means Run the same scroll motion on each variant on the same Android device. Read the dropped-frame count off PerfMonitor (or React Native's JS profiler) for the duration of the scroll. The Inertia variant's dropped-frame count should be **within 5%** of the hand-rolled one — i.e. `dropped_inertia <= dropped_handrolled * 1.05`. If Inertia regresses past 5%, the abstraction is leaking work onto the UI thread that the hand-rolled path avoids — that's a bug, not a tuning issue. ## How to run 1. **Real device.** Simulators don't reproduce the GPU/CPU pressure that surfaces frame drops. Use a mid-range Android (e.g. Pixel 6a or older). Plug into USB so React Native's PerfMonitor can attach. 2. **Release build.** `pnpm --filter @onlynative/inertia-example android --variant release`. PerfMonitor numbers from the dev build are dominated by hot-reload and inspector overhead and won't track production behavior. 3. **Open the screen.** Tap **Perf bench** on the example app's home. The default build uses `FlatList`; see the swap recipe below if you need the canonical FlashList reproduction. 4. **Enable PerfMonitor.** Open the dev menu (shake / volume keys), enable "Perf Monitor". JS frame rate and UI frame rate appear as overlays. 5. **First pass — Inertia.** With the toggle on **Inertia**, scroll fast for ~10 seconds. Note the JS dropped-frame count and the UI dropped-frame count. 6. **Second pass — hand-rolled.** Toggle to **Hand-rolled**, scroll for ~10 seconds with the same motion. Note the same two counts. 7. **Compare.** - JS thread: should be near zero on both — neither variant runs JS per frame. - UI thread: this is where the bar applies. Inertia's count must be within 5% of hand-rolled's. ## What this harness deliberately doesn't do - **No automated 5% assertion.** Real frame measurement needs a device, not CI. The harness is the reproducible part; the comparison is human-driven. - **No JS-side proxy metrics.** `requestAnimationFrame` counts on the JS thread don't capture UI-thread drops, which is the actual bar. Adding a fake JS-side metric would give a false sense of CI coverage. - **No iOS run.** iOS rarely drops frames at this list size; the bar is Android-specific. ## When the bar gets violated The two paths are: - **Inertia row** — `Motion.Pressable gesture={{ pressed: { scale: 0.96 } }} transition={{ type: 'spring', tension: 320, friction: 22 }}`. - **Hand-rolled row** — `Pressable` with manual `useSharedValue(0)` toggling, `useAnimatedStyle` reading `withSpring(1 - pressed.value * 0.04, { stiffness: 320, damping: 22 })`. If Inertia regresses, suspect (in this order): 1. Worklet recreation per render — should be memoized via `mergedSig` / `transitionSig`. Re-check the [memoization regression test](https://github.com/onlynative/inertia/blob/main/packages/core/src/__tests__/memoization.test.tsx). 2. Animated style cost — ensure the worklet body doesn't allocate per frame. 3. Resolver cost — `resolveAnimatableValue` runs on the JS thread, but per-render not per-frame. Check the effect dep array. --- # Testing Inertia ships a Reanimated Jest mock and a `renderWithMotion` helper so existing test suites don't need to learn how the mock is wired before they can assert on animated UI. ## Setup The mock lives at the repo root in `jest.setup.js`. Add it to your Jest config: ```js // jest.config.js module.exports = { preset: 'react-native', setupFiles: [ require.resolve('@onlynative/inertia/jest.setup.js'), // ... your other setup files ], } ``` The mock is **static-render**: animations don't actually run, but `useSharedValue` is `useRef`-backed so values written by an effect persist across re-renders. Combined with `renderWithMotion`, that's enough to assert post-animation styles in unit tests. ## `renderWithMotion` Use it as a drop-in for `@testing-library/react-native`'s `render`. It returns the same render result, with the rendered tree already flushed to the `animate` target. ```ts import { renderWithMotion } from '@onlynative/inertia/testing' import { Motion } from '@onlynative/inertia' it('fades in to opacity: 1', () => { const { getByTestId } = renderWithMotion( , ) expect(getByTestId('card').props.style).toMatchObject({ opacity: 1 }) }) ``` Without the helper, plain `render(...)` returns the `initial` styles — that's the static-render trade-off the mock makes. Inertia tests its own primitives this way too; see `packages/core/src/__tests__/testing-helper.test.tsx`. ## `flushMotion(result, nextUi)` For tests that change props between flushes (a `useState` toggling `animate`, a controller transition), `rerender` once with the new element and then call `flushMotion` to apply the second pass: ```ts const result = renderWithMotion( , ) // later in the test, after some interaction: const next = result.rerender(next) flushMotion(result, next) expect(getStyle(result).opacity).toBe(0.9) ``` `flushMotion` clones the element internally to defeat React's element-reference bail-out, so passing the same element twice in a row works. ## What you can and can't assert The mock only resolves to **terminal targets** — it doesn't simulate frames. So you can: - ✅ Assert post-animation styles (`renderWithMotion` flushes once) - ✅ Spy on `withSpring` / `withTiming` / `withDecay` to verify how Inertia compiles a transition (see `packages/core/src/__tests__/memoization.test.tsx` for the pattern) - ✅ Capture the settle callback from `withSpring` / `withTiming` to fire `onAnimationEnd` manually (see `onAnimationEnd.test.tsx`) You cannot: - ❌ Assert intermediate frames (`opacity` halfway from 0 to 1) - ❌ Make timing-based assertions — there is no timer to advance - ❌ Snapshot the gesture-driven UI (`gesture.pressed`) without firing the corresponding RN event first; the mock doesn't simulate input For frame-level correctness, validate manually in the example app — there's a screen per primitive in [`example/screens/`](https://github.com/onlynative/inertia/tree/main/example/screens). ## Migrating existing tests If your test suite was previously asserting against raw `react-native-reanimated` shared values: ```diff - import { render } from '@testing-library/react-native' + import { renderWithMotion } from '@onlynative/inertia/testing' - const { getByTestId } = render() + const { getByTestId } = renderWithMotion() ``` For tests that previously called `act` + `jest.runAllTimers()` to push animations through, drop both — the mock skips the timer dance and `renderWithMotion` does the rendering shuffle for you. --- # Migrating from raw Reanimated Inertia is a thin wrapper over `react-native-reanimated` — every animation it produces could have been written by hand with `useSharedValue` + `useAnimatedStyle`. The win is that the common patterns collapse into props. This page maps the patterns we keep rewriting in `@onlynative/ui` and other downstream consumers onto the Inertia equivalents. If a pattern below isn't covered, the likely answer is "drop down to the hooks layer" — Inertia exposes `useMotionValue` / `useSpring` / `useTransform` / `useAnimation` / `useGesture` so you don't have to leave the package. ## State-layer fills (Material Design 3) The bread-and-butter pattern: a `Pressable` with a coloured layer that fades in on hover, focus, and press. Roughly 13 components in `@onlynative/ui` share this exact shape. ### Before — raw Reanimated ```tsx import { Pressable } from 'react-native' import Animated, { interpolateColor, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated' const AnimatedPressable = Animated.createAnimatedComponent(Pressable) export function Button({ onPress, label }: Props) { const press = useSharedValue(0) const focus = useSharedValue(0) const hover = useSharedValue(0) const animatedStyle = useAnimatedStyle(() => { 'worklet' let bg = interpolateColor(hover.value, [0, 1], ['transparent', '#0001']) bg = interpolateColor(focus.value, [0, 1], [bg, '#0002']) bg = interpolateColor(press.value, [0, 1], [bg, '#0003']) return { backgroundColor: bg } }) return ( (press.value = withTiming(1, { duration: 120 }))} onPressOut={() => (press.value = withTiming(0, { duration: 120 }))} onFocus={() => (focus.value = withTiming(1, { duration: 120 }))} onBlur={() => (focus.value = withTiming(0, { duration: 120 }))} onHoverIn={() => (hover.value = withTiming(1, { duration: 120 }))} onHoverOut={() => (hover.value = withTiming(0, { duration: 120 }))} style={[styles.button, animatedStyle]} > {label} ) } ``` ### After — Inertia ```tsx import { Motion } from '@onlynative/inertia' export function Button({ onPress, label }: Props) { return ( {label} ) } ``` Three shared values, one animated style, and six handler callbacks collapse into one `gesture` prop. ### Layered blending preserves the cross-fade The chained-`interpolateColor` form above blends three independent layers so a release-while-still-hovered shows a real cross-fade. Inertia's `gesture` matches that semantically: each declared sub-state owns its own progress (0↔1) and the worklet composites layers in priority order (`hovered → focused → focusVisible → pressed`). When you release while still hovered, the press layer fades back to 0 independently — the hover layer stays at 1, so the value lands on the hover target rather than snapping back to base. To configure per-layer fade timing (MD3 spec uses ~50 ms in, ~150 ms out), pass per-state entries on `transition`: ```tsx transition={{ backgroundColor: { type: 'timing', duration: 120 }, pressed: { type: 'timing', duration: 50 }, hovered: { type: 'timing', duration: 90 }, }} ``` Without per-layer entries, layers inherit the top-level `transition` (or fall back to the library default spring). ### `style` must be a value, not a function `Motion.Pressable` inherits Reanimated's `createAnimatedComponent` wrapper, which silently drops the function-form `style={({ pressed }) => ...}` that RN's `Pressable` accepts. Drive press/focus/hover styling through `gesture` (as above) or compute conditional styles once in render. See [primitives/pressable](../primitives/pressable#style-must-be-a-value-not-a-function) for the full caveat. ### When the gesture drives sibling overlays — `useGesture` The `gesture` prop animates the receiver's _own_ style. If you have a focus ring rendered as a sibling, an MD3 state-layer halo overlaid on the content, or a content tint that needs to live on a child View, the prop can't reach those siblings — but [`useGesture`](../api/hooks#usegesturetransition) can. It returns the same four 0↔1 progress shared values the prop is built on, plus a handler bag to spread on a `Pressable`. Feed the shared values into as many `useAnimatedStyle` blocks as you need. ```tsx import { useGesture } from '@onlynative/inertia' import Animated, { interpolateColor, useAnimatedStyle, } from 'react-native-reanimated' export function ButtonWithRing({ onPress, label }: Props) { const { pressed, focused, focusVisible, hovered, handlers } = useGesture({ pressed: { type: 'timing', duration: 100 }, hovered: { type: 'timing', duration: 150 }, focused: { type: 'timing', duration: 200 }, }) const ringStyle = useAnimatedStyle(() => ({ opacity: focusVisible.value, })) const bgStyle = useAnimatedStyle(() => { let bg = interpolateColor(hovered.value, [0, 1], ['transparent', '#0001']) bg = interpolateColor(focused.value, [0, 1], [bg, '#0002']) bg = interpolateColor(pressed.value, [0, 1], [bg, '#0003']) return { backgroundColor: bg } }) return ( {label} ) } ``` `useGesture` and the `gesture` prop share the same machinery, semantics, and reduced-motion gating — they're two surfaces over one engine. The rule of thumb: if your animation lives entirely on one component's style, use the prop; if it spans siblings, use the hook. ## Mount-on-appear (fade in, slide up) ### Before ```tsx const opacity = useSharedValue(0) const translateY = useSharedValue(20) useEffect(() => { opacity.value = withTiming(1, { duration: 200 }) translateY.value = withSpring(0, { stiffness: 180, damping: 22, mass: 1 }) }, []) const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, transform: [{ translateY: translateY.value }], })) return ``` ### After ```tsx ``` The Reanimated config translates 1:1 — `stiffness` → `tension`, `damping` → `friction`, `mass` → `mass`. See the [transition shapes](../transitions#porting-from-raw-reanimated-stiffness--damping) for the table. ## Toggle progress (Switch / Checkbox / Radio) A boolean that animates between two states. ### Before ```tsx const progress = useSharedValue(checked ? 1 : 0) useEffect(() => { progress.value = withSpring(checked ? 1 : 0, { stiffness: 380, damping: 32, }) }, [checked]) const thumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: progress.value * 24 }], })) const trackStyle = useAnimatedStyle(() => ({ backgroundColor: interpolateColor( progress.value, [0, 1], [colors.surface, colors.primary], ), })) ``` ### After ```tsx const variants = { off: { translateX: 0, backgroundColor: colors.surface }, on: { translateX: 24, backgroundColor: colors.primary }, } as const ``` One variant map, one prop swap on `animate`. `useEffect` and the manual `withSpring` call both go away. For programmatic chaining (`onChange` with async work, ripple animations), use `useVariants(variants)` instead and drive the controller from JS: ```tsx const controller = useVariants(variants) controller.transitionTo(checked ? 'on' : 'off') ``` ### When one progress drives multiple sibling views — `useAnimation` `variants` lives on one Motion primitive. A real Switch / Checkbox is three or four views (track, thumb, ripple halo) that all read the same progress. The native pattern with [`useAnimation`](../api/hooks#useanimationtarget-transition) — Inertia's general-purpose "drive a `SharedValue` toward a target with any transition" hook — keeps the progress shared across as many `useAnimatedStyle` blocks as you need. ```tsx import { useAnimation, useGesture } from '@onlynative/inertia' import Animated, { interpolate, interpolateColor, useAnimatedStyle, } from 'react-native-reanimated' export function Switch({ value, onValueChange }: Props) { const progress = useAnimation(value ? 1 : 0, { type: 'spring', tension: 380, friction: 33, }) const { pressed, focused, hovered, handlers } = useGesture({ pressed: { type: 'timing', duration: 120 }, hovered: { type: 'timing', duration: 150 }, focused: { type: 'timing', duration: 200 }, }) const trackStyle = useAnimatedStyle(() => ({ backgroundColor: interpolateColor( progress.value, [0, 1], [colors.surface, colors.primary], ), })) const thumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: interpolate(progress.value, [0, 1], [0, 24]) }], })) const haloStyle = useAnimatedStyle(() => ({ opacity: Math.max( hovered.value * 0.08, focused.value * 0.1, pressed.value * 0.1, ), })) return ( onValueChange(!value)} style={styles.track} > ) } ``` What collapsed: - `const progress = useSharedValue(value ? 1 : 0)` + a `useEffect` that calls `withSpring` — replaced by one `useAnimation(value ? 1 : 0, transition)`. - Three press/focus/hover `useSharedValue` calls + six `useCallback` handlers — replaced by one `useGesture(transition)` destructure plus `{...handlers}`. - Stiffness/damping rename → tension/friction rename (numerically identical; just designer-friendly names). - `isFocusVisible()` modality logic baked into the hook — no consumer wiring required. `useAnimation` also handles indeterminate progress (loops, sequences) — see [Looping / infinite animations](#looping--infinite-animations) below. ## Drag / pan / swipe These wait for the [`@onlynative/inertia-gestures`](../gestures-adapter) adapter (PanGestureHandler under the hood). The hooks return ready-made shared values you wire into the same props — no `setNativeProps`, no manual `withDecay` after release. ```tsx import { useDrag } from '@onlynative/inertia-gestures' const { gesture, animatedStyle } = useDrag({ axis: 'x', constraints: { left: -100, right: 100 }, elastic: 0.3, }) ``` If your gesture layer is already PanResponder-based and you don't want to add a peer dep, the existing code keeps working — Inertia doesn't replace `react-native-gesture-handler` flows in the core package. ## Looping / infinite animations ### Before ```tsx const angle = useSharedValue(0) useEffect(() => { angle.value = withRepeat( withTiming(360, { duration: 1200 }), -1, // -1 means infinite false, // don't reverse ) }, []) const style = useAnimatedStyle(() => ({ transform: [{ rotate: `${angle.value}deg` }], })) ``` ### After ```tsx ``` The three `withRepeat` flags collapse into one shape. See [sequences and repeat](../sequences#repeat). ### Indeterminate progress (a loop on a standalone shared value) When the loop has to drive a shared value the rest of your component reads — an indeterminate `LinearProgress` slide, a `CircularProgress` rotation, an idle spinner whose rotation feeds multiple animated styles — there's no Motion primitive to attach `animate` to. [`useAnimation`](../api/hooks#useanimationtarget-transition) carries the same repeat config to a standalone `SharedValue`: ```tsx import { useAnimation } from '@onlynative/inertia' import Animated, { useAnimatedStyle } from 'react-native-reanimated' export function Spinner() { const rotation = useAnimation(1, { type: 'timing', duration: 1400, repeat: { count: 'infinite', alternate: false }, }) const style = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value * 360}deg` }], })) return } ``` The same SV could feed a second `useAnimatedStyle` block (e.g. a trailing dot whose opacity tracks `rotation.value`), which is the win over `Motion.View animate={{ rotate: 360 }}`: one driver, many consumers. When the component unmounts the SV is collected and the animation stops — no `cancelAnimation` boilerplate. ## Custom easing Reanimated 3.9+ validates that `easing` is a worklet inside nested-transition contexts (variants, sequences, per-property maps). A bare arrow function crashes there. ### Before ```tsx import { Easing } from 'react-native-reanimated' // Crashes inside a variant if you forget the directive. const easing = (t: number) => { 'worklet' return Math.pow(t, 3) } ``` ### After ```tsx // Plain function — the resolver wraps it as a worklet at JS time. const easing = (t: number) => Math.pow(t, 3) ``` Easing fns must still be **pure** — no captured JS-thread refs, no closures over component state. The wrapping only fixes the worklet-validation; it can't safely move JS-thread state across the boundary. ## Mount/unmount transitions (`AnimatePresence`) If you have an existing `AnimatePresence` from another lib, swap the import: ```diff - import { AnimatePresence } from 'some-other-lib' + import { Presence } from '@onlynative/inertia' function Toast({ visible }: Props) { return ( - + {visible ? ( ) : null} - + ) } ``` `` automatically applies `pointerEvents: 'none'` to exiting children — the "two clicks to re-open the popover" bug doesn't reproduce. See [Presence](../presence) for the full contract. ## When _not_ to migrate Some patterns are still better off as raw Reanimated: - **Frame-by-frame data viz** — d3-style charts that read shared values inside `useDerivedValue` and feed them into SVG props. The Inertia public surface targets `style` keys; SVG attribute interpolation lives in the hooks layer or in raw Reanimated. - **Custom physics simulations** — anything where you'd be reaching into `withDecay` callback signatures, `cancelAnimation`, or `runOnUI` directly. Drop down to the hooks. - **Layout / shared-element transitions** — deferred to v1.x. If you're animating list reordering or screen-to-screen hero transitions, keep using Reanimated's `Layout` API directly for now. - **Slider / continuous gesture range UI** — until [`@onlynative/inertia-gestures`](../gestures-adapter) covers the pattern (v0.2 still in flight), keep the hand-rolled PanResponder + `useSharedValue` flow. The hooks layer is intentionally the same shape as Reanimated's so dropping down doesn't feel like switching tools. ## Testing migrated components The Reanimated mock that ships with Jest is **static-render-only** — animations don't actually run, and `useAnimatedStyle` is captured at the at-rest values. After migrating, your existing tests assert against `initial` styles by default, which is usually wrong. Inertia ships a test helper that flushes animations to their target state in one call. See [Testing](../testing) for the API. ## Stuck? Open an issue with the before/after pair you're trying to migrate. The patterns in this guide came from `@onlynative/ui`'s real components — if your shape isn't covered, it should be. --- # Hooks The escape-hatch surface — drop here when you need imperative control beyond what the props expose. The value-layer hooks (`useMotionValue`, `useSpring`, `useTransform`, `useScroll`) compose with `useAnimatedStyle` and every other Reanimated primitive — they return real shared values, not wrapped abstractions. Reach for them when an animation is gesture-driven, scroll-driven, or otherwise needs to live outside the declarative `animate` flow. ## `useMotionValue(initial)` Create an animatable value owned by JS but readable from worklets. A thin pass-through over Reanimated's `useSharedValue` — the returned `SharedValue` works anywhere a shared value is accepted (`useAnimatedStyle`, `useDerivedValue`, the other value hooks below). ```tsx import { useMotionValue, Motion } from '@onlynative/inertia' import { useAnimatedStyle } from 'react-native-reanimated' function Draggable() { const x = useMotionValue(0) const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }], })) return ( (x.value = e.nativeEvent.pageX)} /> ) } ``` We intentionally don't wrap the shared value in a `MotionValue` class — adding a `{ get, set, value }` shell would force consumers to unwrap it at every Reanimated boundary and would break worklet capture. The bare shared value _is_ the public surface. | Signature | Returns | | -------------------------------------------------------- | ---------------- | | `useMotionValue(initial: T)` | `SharedValue` | ## `useSpring(target, config?)` Animate a shared value toward `target` with spring physics, using Inertia's react-spring vocabulary (`tension` / `friction` / `mass`). `target` can be a plain number — the spring re-runs whenever the prop changes — or another `SharedValue`, in which case the spring is driven by a UI-thread reaction. Both paths bottom out at the same `withSpring` call; the split is just which thread observes the source. ```tsx import { useMotionValue, useSpring, Motion } from '@onlynative/inertia' import { useAnimatedStyle } from 'react-native-reanimated' function Followable({ targetX }: { targetX: number }) { // Plain-number path: re-springs whenever `targetX` changes. const x = useSpring(targetX, { tension: 200, friction: 18 }) const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }], })) return } function Chained() { // SharedValue path: drag drives `dragX`, spring smooths it into `x`. const dragX = useMotionValue(0) const x = useSpring(dragX) const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }], })) return } ``` | Signature | Returns | | ----------------------------------------------------------------------------- | --------------------- | | `useSpring(target: number \| SharedValue, config?: SpringTransition)` | `SharedValue` | Config accepts every field of [`SpringTransition`](../transitions): `tension`, `friction`, `mass`, `velocity`, `restSpeedThreshold`, `restDisplacementThreshold`. Reanimated's raw `stiffness` / `damping` names are never accepted — that conversion is private to the library. ## `useTransform(...)` Derive a value from one or more shared values. Two overloads: ### Interpolation Map a numeric shared value onto a range of numbers or colors. Output type drives whether the underlying call is `interpolate` (numerics) or `interpolateColor` (color strings). ```tsx import { useMotionValue, useTransform } from '@onlynative/inertia' const scroll = useMotionValue(0) const headerOpacity = useTransform(scroll, [0, 100], [1, 0]) const headerTint = useTransform(scroll, [0, 100], ['#ffffff', '#000000']) ``` | Signature | Returns | | ---------------------------------------------------------------------------------------------------------------------- | --------------------- | | `useTransform(value: SharedValue, inputRange: number[], outputRange: number[], options?: UseTransformOptions)` | `SharedValue` | | `useTransform(value: SharedValue, inputRange: number[], outputRange: string[], options?: UseTransformOptions)` | `SharedValue` | `UseTransformOptions`: | Field | Type | Default | Notes | | ------------------ | ----------------------------------- | --------- | ------------------------------------- | | `extrapolateLeft` | `'clamp' \| 'identity' \| 'extend'` | `'clamp'` | Behavior below the first input value. | | `extrapolateRight` | `'clamp' \| 'identity' \| 'extend'` | `'clamp'` | Behavior above the last input value. | The input range must be monotonically increasing. ### Transformer worklet Derive any value from any number of shared values via a worklet. ```tsx const x = useMotionValue(0) const y = useMotionValue(0) const distance = useTransform(() => Math.sqrt(x.value ** 2 + y.value ** 2)) ``` | Signature | Returns | | --------------------------------------- | ---------------- | | `useTransform(transformer: () => T)` | `SharedValue` | The transformer must be a worklet. Plain functions are auto-wrapped with the `'worklet'` directive at JS time — the same treatment user-supplied easing gets — so you don't need to remember it. The transformer must be pure: no captured JS-thread refs, no calls to non-worklet APIs. ## `useAnimation(target, transition?)` The general-purpose value-layer hook: drive a `SharedValue` toward `target` with any `TransitionConfig`. Reach for it when you need raw `useSharedValue + useEffect + withTiming` (or `withSpring`, or `withRepeat`) **outside** the declarative `animate` flow — boolean state progress on a widget with multiple animated children, indeterminate progress on a list of `useAnimatedStyle` consumers, anywhere the value layer is the right abstraction. ```tsx import { useAnimation } from '@onlynative/inertia' // Toggle progress driven by a prop. Spring physics, react-spring vocab. const progress = useAnimation(isChecked ? 1 : 0, { type: 'spring', tension: 380, friction: 33, }) // Float a label when the field has a value. Timing curve. const floated = useAnimation(hasValue ? 1 : 0, { type: 'timing', duration: 150, }) // Indeterminate progress slider — loops forever, snaps back each cycle. const slide = useAnimation(1, { type: 'timing', duration: 1800, repeat: { count: 'infinite', alternate: false }, }) ``` | Signature | Returns | | ------------------------------------------------------------- | --------------------- | | `useAnimation(target: number, transition?: TransitionConfig)` | `SharedValue` | The hook re-runs the animation whenever `target` changes or the transition's structural signature changes. A fresh literal each render (`{ type: 'timing', duration: 200 }` rebuilt on every call) doesn't re-fire — only structural changes do. Reduced motion (``) collapses the transition to `'no-animation'` and snaps the value. **`useAnimation` vs `useSpring`.** They overlap on the spring case — `useAnimation(target, { type: 'spring', ... })` and `useSpring(target, { ... })` produce the same animation. Prefer `useSpring` when you only want spring physics; it also accepts a `SharedValue` as the target for UI-thread-reactive smoothing (a gesture-driven smoothing source). `useAnimation` is the general-purpose hook — accepts any `TransitionConfig` including `timing`, `decay`, `no-animation`, and `repeat` — but is JS-thread-driven only. **`useAnimation` vs `Motion.View animate={{...}}`.** Use the `animate` prop when one Motion primitive owns the animated value end-to-end. Use `useAnimation` when the same value drives several `useAnimatedStyle` blocks across siblings — there's no Motion primitive to attach `animate` to in that case. ## `useScroll()` Track the scroll offset of a `Motion.ScrollView` as shared values. Scroll events fire on the UI thread, so the returned values are safe to read from any worklet without a JS-thread bounce. ```tsx import { useScroll, useTransform, Motion } from '@onlynative/inertia' import { useAnimatedStyle } from 'react-native-reanimated' function StickyHeader() { const { scrollY, onScroll } = useScroll() const headerOpacity = useTransform(scrollY, [0, 100], [1, 0]) // Shared values feed `useAnimatedStyle`, not `animate`. The `animate` prop // takes plain values that the resolver bakes into `withSpring` / // `withTiming` calls on the JS thread; shared values are the right fit // for continuous, externally-driven inputs (scroll, gestures). const headerStyle = useAnimatedStyle(() => ({ opacity: headerOpacity.value })) return ( <> {/* …content… */} ) } ``` Returns: | Field | Type | Notes | | ---------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `scrollX` | `SharedValue` | Horizontal scroll offset in points. | | `scrollY` | `SharedValue` | Vertical scroll offset in points. | | `onScroll` | `(event: NativeSyntheticEvent) => void` | Pass to `Motion.ScrollView`'s `onScroll` prop. Worklet-backed; safe to forward to any Reanimated-animated scrollable. | Set `scrollEventThrottle={16}` on the `ScrollView` for steady 60 Hz updates; without it, Android can dispatch less frequently than iOS. ## `useGesture(transition?)` The hook-form of the [`gesture` prop](../gestures). Reach for it when one Pressable's gesture state needs to drive multiple animated views — a focus ring rendered as a sibling, an MD3 state-layer halo that overlays the content, a content tint and a separate icon-color animation, etc. The prop-form only animates the receiver's own style; the hook gives you the underlying 0↔1 progress shared values to feed into any number of `useAnimatedStyle` blocks. ```tsx import { useGesture } from '@onlynative/inertia' import Animated, { interpolateColor, useAnimatedStyle, } from 'react-native-reanimated' function StateLayerButton() { const { pressed, focused, focusVisible, hovered, handlers } = useGesture({ pressed: { type: 'timing', duration: 100 }, hovered: { type: 'timing', duration: 150 }, focused: { type: 'timing', duration: 200 }, }) const ringStyle = useAnimatedStyle(() => ({ opacity: focusVisible.value })) const haloStyle = useAnimatedStyle(() => ({ opacity: Math.max( hovered.value * 0.08, focused.value * 0.1, pressed.value * 0.1, ), })) return ( ) } ``` Returns: | Field | Type | Notes | | -------------- | --------------------- | -------------------------------------------------------------------------------------------------- | | `pressed` | `SharedValue` | 0↔1 progress for the pressed layer. | | `focused` | `SharedValue` | 0↔1 progress for any focus modality. | | `focusVisible` | `SharedValue` | 0↔1 progress for keyboard-only focus (W3C `:focus-visible` semantics). | | `hovered` | `SharedValue` | 0↔1 progress for hover (web only — stays at 0 on native). | | `handlers` | `UseGestureHandlers` | `{ onPressIn, onPressOut, onHoverIn, onHoverOut, onFocus, onBlur }`. Spread on the host Pressable. | Transitions follow the same shape as the `gesture` prop's accompanying `transition`: - `useGesture({ type: 'timing', duration: 150 })` — same config for every layer. - `useGesture({ pressed: {...}, hovered: {...}, focused: {...}, focusVisible: {...} })` — per-layer. - Layers without an explicit transition fall back to the library default spring. - `` is respected — when reduced motion is active, every transition collapses to `'no-animation'` and progress snaps instead of interpolating. The shared values and the handler bag are **identity-stable** across renders — safe to pass to memoized children. To compose with consumer handlers (e.g. your own `onPressIn` for analytics), wrap manually: ```tsx { track('press') handlers.onPressIn() }} /> ``` When `useGesture` and the `gesture` prop describe the same scenario, prefer the prop — fewer moving parts. The hook is the escape hatch for compositions the prop can't express. ## `useVariants(variants, initial?)` Build a controller for a variants map. Pass it through `controller={...}` to drive transitions imperatively. ```ts import { useVariants } from '@onlynative/inertia' const variants = { open: { opacity: 1, translateY: 0 }, closed: { opacity: 0, translateY: 100 }, } as const const controller = useVariants(variants, 'closed') controller.transitionTo('open') console.log(controller.current) // 'open' ``` Returns `{ current, transitionTo, subscribe }`: | Field | Type | Notes | | -------------- | ---------------------------------- | ---------------------------------------------------------------- | | `current` | `keyof V & string` | Active variant key. Read-only — change it via `transitionTo`. | | `transitionTo` | `(next: keyof V & string) => void` | No-op if `next === current`. Warns on unknown keys in dev. | | `subscribe` | `(listener) => () => void` | Internal. Motion primitives subscribe via the `controller` prop. | The controller is identity-stable — the hook returns the same object across renders. See [Variants](../variants) for the props-side usage. ## `useMotionConfig()` Read the active `` value: ```ts import { useMotionConfig } from '@onlynative/inertia' const { reducedMotion } = useMotionConfig() // 'user' | 'never' | 'always' ``` Returns the default (`{ reducedMotion: 'user' }`) when no provider is in the tree. ## `useShouldReduceMotion()` Resolve the active reduced-motion mode to a boolean. `'user'` consults Reanimated's OS-backed hook; `'never'` and `'always'` shortcut to `false` / `true`. ```ts import { useShouldReduceMotion } from '@onlynative/inertia' function MyVideoIntro() { const reduce = useShouldReduceMotion() if (reduce) return return } ``` This is what every Motion primitive uses to decide whether to swap transitions for `'no-animation'`. Subscribes to OS changes — components re-render when the user toggles the setting at runtime. ## `usePresence()` Read the per-child presence context inside a custom component you'd like to behave like a Motion primitive under ``: ```ts import { usePresence } from '@onlynative/inertia' function CustomExitable() { const presence = usePresence() // presence is null when no ancestor exists // presence?.isPresent flips false when the parent removes us // presence?.safeToRemove() — call once exit completes } ``` See [Presence](../presence) for the higher-level prop-driven usage. --- # createMotionComponent Wrap any React component with the same Motion prop surface. The component's `style` prop type is inferred and flows through to `animate` / `initial` / `exit` / `gesture` — no shared union fallback, no need to specify the style type by hand. ## Signature ```ts function createMotionComponent>( Component: C, ): MotionComponent ``` ## Wrapping a third-party component Most third-party RN primitives can be animated as long as they forward `style` to a host node. For example, wrapping `expo-image`: ```tsx import { Image } from 'expo-image' import { createMotionComponent } from '@onlynative/inertia' export const MotionExpoImage = createMotionComponent(Image) // `animate` is inferred from expo-image's `style` prop ``` ## Wrapping a styled component If you have a wrapper component that exposes `style`, the same applies: ```tsx import { createMotionComponent } from '@onlynative/inertia' import { Card } from './Card' export const MotionCard = createMotionComponent(Card) ``` Inertia uses Reanimated's `createAnimatedComponent` internally, so the wrapped component must accept a `style` prop that ends up on a host element. Components that wrap their style multiple layers deep without forwarding may fail to animate — Reanimated needs a path to the host node. ## What you get - `animate`, `initial`, `exit`, `transition`, `variants`, `controller`, `gesture`, `onAnimationEnd` — all the standard Motion props. - Per-component `style` inference: `tintColor` will autocomplete on a wrapped image but be rejected on a wrapped view. - The same memoization, sequence, and reduced-motion behavior every built-in `Motion.*` primitive enjoys. ## Caveats - The function-style `style={(state) => ...}` Pressable form is not supported by the factory. Drive press-state styling through `gesture.pressed` instead. - Components that re-shape `style` before forwarding (e.g. transforming a string into a stylesheet ref) may not animate cleanly. Test with a single transform or opacity before relying on it for production.