# 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/rootnative/inertia > Site: https://rootnative.github.io/inertia/ --- Inertia (`@rootnative/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.0-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/rootnative/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 (`@rootnative/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 @rootnative/inertia react-native-reanimated react-native-worklets ``` Then enable the `react-native-worklets/plugin` Babel plugin per the Reanimated [install guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation) — see [Installation](./installation) for a full `babel.config.js`. ## A first animation ```tsx import { Motion } from '@rootnative/inertia' export function FadeIn() { return ( ) } ``` For tree-shaking when only one primitive is used: ```ts import { MotionView } from '@rootnative/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. - [AI agents & llms.txt](./ai) — machine-readable API references for coding agents, hosted here and shipped in every npm package. --- # 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` - `react-native-worklets >= 0.5.0` — the worklet runtime Reanimated 4 is built on. It is a **peer** of both Reanimated and Inertia, so Yarn and pnpm will not install it for you. ## Install Yarn is the default. Switch tabs for npm, pnpm, or Bun — your selection persists across the docs. ```bash yarn add @rootnative/inertia react-native-reanimated react-native-worklets ``` Then enable the worklets Babel plugin per the Reanimated [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-worklets/plugin'], } } ``` The worklets plugin must be **last** in the plugins array. (`react-native-reanimated/plugin` still works in Reanimated 4 — it is a one-line shim that forwards to `react-native-worklets/plugin` — but the worklets name is the canonical one, and it's the one Inertia's own dev-time install check tells you to add.) ## First animation ```tsx import { Motion } from '@rootnative/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 '@rootnative/inertia/view' import { MotionText } from '@rootnative/inertia/text' import { MotionImage } from '@rootnative/inertia/image' import { MotionPressable } from '@rootnative/inertia/pressable' import { MotionScrollView } from '@rootnative/inertia/scroll-view' ``` `@rootnative/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 '@rootnative/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 '@rootnative/inertia'` (or the subpath) when bundle size matters. ## Optional adapter packages Three sibling packages extend Inertia for capabilities that need extra peer dependencies. The core library has no required dependency on any of them — install only what you need. ### Gestures (`@rootnative/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 @rootnative/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 (`@rootnative/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 @rootnative/inertia-gradients expo-linear-gradient ``` See [Gradients](./gradients) for the primitive. ### SVG (`@rootnative/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 @rootnative/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`, `layout`, `layoutId`). | 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`, `shadowOpacity`, `shadowRadius`, `elevation`. Rotation values are degrees as numbers — the runtime wraps with `'deg'` before handing to Reanimated. **Shadow offset:** `shadowOffset` accepts the nested `{ width, height }` object (single-value form only — see [Motion.View](./view) for the contract). **Color:** `backgroundColor`, `borderColor`, `color`, `shadowColor`, `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 [`@rootnative/inertia-gradients`](../gradients) (`MotionLinearGradient`), and SVG path morphing ships in [`@rootnative/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. **Shared element transitions:** the [`layoutId` prop](../layout#shared-element-transitions-layoutid) FLIPs between two `Motion.*` primitives that mount with the same id — cross-mount or cross-screen, without any explicit animation config beyond the shared id. --- # 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 '@rootnative/inertia' export function Card() { return ( ) } ``` ## Tree-shaken import ```ts import { MotionView } from '@rootnative/inertia/view' ``` ## Animatable keys `opacity`, `translateX`, `translateY`, `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`, `rotateY`, `width`, `height`, `borderRadius`, `shadowOpacity`, `shadowRadius`, `elevation`, `backgroundColor`, `borderColor`, `shadowColor`, `shadowOffset`. ## Shadow animation Shadow keys ride the same animatable pipeline as other numeric / color props — `shadowOpacity`, `shadowRadius`, `elevation` are numerics; `shadowColor` is a color. `shadowOffset` is the one nested-object style on the surface; internally the worklet decomposes it into two synthetic axis SVs and recomposes them into a single `{ width, height }` prop each frame. ```tsx // MD3 elevation cascade — flat numerics + the nested shadowOffset ``` `shadowOffset` v0.1 supports the **single-value form only** — `{ width: number, height: number }`. Sequences inside the nested object (`{ width: [0, 100, 0], height: 0 }`) and array keyframes on the whole object are out of scope; drop to the value-layer hooks (`useMotionValue` + `useAnimatedStyle`) when you need them. Per-axis transition splits are also out of scope — the top-level `transition.shadowOffset` applies to both axes. ## 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. - Shadow rendering is platform-specific: iOS uses `shadowColor` / `shadowOpacity` / `shadowOffset` / `shadowRadius`; Android uses `elevation` (which derives its own shadow). Animate both sets together when you need a cross-platform cascade. --- # Motion.Text Animatable `Text`. `animate` / `initial` / `exit` / `gesture` are typed against `TextStyle`. ```tsx import { Motion } from '@rootnative/inertia' export function Heading({ visible }: { visible: boolean }) { return ( Hello ) } ``` ## Tree-shaken import ```ts import { MotionText } from '@rootnative/inertia/text' ``` ## Animatable keys (alpha) `opacity`, `translateX`, `translateY`, `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`, `rotateY`, `width`, `height`, `borderRadius`, `color`, `backgroundColor`. `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 '@rootnative/inertia' export function Avatar({ source }: { source: { uri: string } }) { return ( ) } ``` ## Tree-shaken import ```ts import { MotionImage } from '@rootnative/inertia/image' ``` ## Animatable keys (alpha) `opacity`, `translateX`, `translateY`, `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`, `rotateY`, `width`, `height`, `borderRadius`, `tintColor`, `backgroundColor`, `borderColor`. ```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 '@rootnative/inertia' export function PrimaryButton({ onPress }: { onPress: () => void }) { return ( ) } ``` ## Tree-shaken import ```ts import { MotionPressable } from '@rootnative/inertia/pressable' ``` ## `style` must be a value, not a function This is the **#1 footgun** for users coming from RN's `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. Reanimated's `createAnimatedComponent` wrapper doesn't invoke it — the function would be silently dropped — so `Motion.Pressable` (and every other `Motion.*` primitive) **throws in dev** when `style` is a function, rather than ship the footgun. ```tsx // ❌ Throws in dev. Drive press-state through `gesture` instead. [ styles.button, pressed && styles.buttonPressed, ]} /> // ✅ ``` The throw happens behind `__DEV__`, so production builds don't pay the check — the styles would still be dropped silently there, which is why catching it in dev is load-bearing. 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 '@rootnative/inertia' export function AnimatedFeed() { return ( {/* rows */} ) } ``` ## Tree-shaken import ```ts import { MotionScrollView } from '@rootnative/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 ``` ## Named transitions Anywhere a config object is accepted — top-level, per-property, per gesture layer, the `layout` prop, and the value-layer hooks — a name registered on the nearest [``](./motion-config#named-transitions) is accepted too: ```tsx ``` Unknown names warn in development and fall back to the default spring. See [MotionConfig → Named transitions](./motion-config#named-transitions) for merge semantics and typed-name augmentation. ## 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, }} ``` Custom `easing` functions must be worklets — put the `'worklet'` directive as the function's first statement (Reanimated's built-in `Easing.*` helpers and inertia's `cubicBezier()` already are). A plain function warns in dev: the fallback wrapper works on web but native builds reject it when the transition runs on the UI thread, because the wrapper's closure holds the opaque plain function. Easing fns must also be pure — no captured JS-thread refs. ### Easing input shape `easing` accepts either a plain function `(t: number) => number` or a Reanimated 4 `EasingFunctionFactory` (`{ factory: () => EasingFunction }`). The resolver detects the factory shape and unwraps it automatically: ```tsx import { Easing } from 'react-native-reanimated' // Pass the factory directly — no `.factory()` call site needed. transition={{ type: 'timing', duration: 200, easing: Easing.bezier(0.2, 0, 0, 1), }} ``` Any of the three forms — a plain `(t: number) => number` function, a pre-worklet'd easing, or a Reanimated `Easing.bezier(...)`-style factory — is accepted wherever `easing` appears. ### `cubicBezier()` — CSS easing tokens Design systems usually store easing tokens in the W3C CSS format. `cubicBezier` builds an `easing` value from any of the three CSS spellings, so a token file is directly consumable without hand-translating to `Easing.bezier` calls: ```tsx import { cubicBezier } from '@rootnative/inertia' cubicBezier(0.2, 0, 0, 1) // number form cubicBezier('cubic-bezier(0.2, 0, 0, 1)') // CSS token form cubicBezier('ease-out') // CSS keywords, incl. 'linear' transition={{ type: 'timing', duration: 200, easing: cubicBezier(theme.motion.easingStandard), }} ``` The keywords map to their canonical css-easing-1 curves (`ease`, `ease-in`, `ease-out`, `ease-in-out`; `linear` maps to the identity easing). Invalid input throws at call time — a malformed token should fail at theme setup, not silently animate with the wrong curve. Per the CSS spec, `x1` / `x2` must be within `[0, 1]`; `y1` / `y2` may overshoot. The stepping keywords (`step-start` / `step-end`) and the CSS `linear(...)` function are not supported. Combined with [named transitions](./motion-config#named-transitions), this is the recommended way to feed a theme's motion tokens into Inertia: ```tsx ``` ### `'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 sources of truth are `packages/core/src/transitions/spring.ts` (`DEFAULT_SPRING`) for the spring rows and `packages/core/src/transitions/resolve.ts` for the timing rows. --- # 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 '@rootnative/inertia' const variants = { closed: { translateY: 100, opacity: 0 }, open: { translateY: 0, opacity: 1 }, } as const export function Sheet({ isOpen }: { isOpen: boolean }) { return ( ) } ``` Variant keys autocomplete on `animate` (and a typo is a compile error) with no annotation required — the map's keys are inferred at the JSX call site. `as const` is optional; add it only if you also want the variant _values_ readonly. ## 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 '@rootnative/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 }` controller. `current` is the active key; `transitionTo(next)` re-applies the matching variant on every Motion primitive the controller is attached to. (The controller also carries an internal `subscribe` used by the primitives themselves — not part of the public contract.) 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 (a different transition per variant target) aren't supported; switch the wrapping primitive's `transition` based on the active key if you need that. --- # 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 composite as a **priority cascade** 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 want MD3 state-layer overlays — `useGestureLayer` The `gesture` prop composites states as a fixed **priority cascade** — when hovered and pressed are both fully active, the value converges to the `pressed` target because it sits highest in the cascade, regardless of which target is numerically stronger. MD3 state-layer haloes and iOS-translucent overlays want a different model: **clamped-max**, where simultaneously hovered + pressed shows whichever target is _stronger_ per-key, whatever its priority. [`useGestureLayer`](./api/hooks#usegesturelayerstates-options) at `@rootnative/inertia/gesture-layer` is the convenience layer for that model — supply per-state target maps (`rest` / `hovered` / `focused` / `focusVisible` / `pressed` / `disabled`), the hook owns the worklet, the disabled override, and the transition. ```tsx import { useGestureLayer } from '@rootnative/inertia/gesture-layer' const { style, handlers } = useGestureLayer({ rest: { opacity: 0, backgroundColor: 'transparent' }, hovered: { opacity: 0.08, backgroundColor: '#000' }, pressed: { opacity: 0.12, backgroundColor: '#000' }, }) ``` Numeric keys compose via clamped-max; color keys compose via priority cascade (`hovered → focused → focusVisible → pressed`); `disabled` sits on top of both. The result also carries `states` — the per-state 0↔1 progress shared values behind the composed style — so extra styles can derive from the same gesture wiring (e.g. `useShadow({ from, to, progress: states.hovered })` for an elevation crossfade) without a second `useGesture` call. Reach for plain `useGesture` if the composition model doesn't fit (additive blends, multiply, per-key custom rules). ## 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 `@rootnative/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 @rootnative/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 '@rootnative/inertia' import { useDrag } from '@rootnative/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. | | `onRelease` | `(info) => ReleaseResult \| void` (worklet) | none | UI-thread release transition — see below. | Returns `{ gesture, animatedStyle, dragX, dragY, isDragging }`. The shared values are exposed for power use cases (deriving secondary effects from drag position). ### Release transitions (`onRelease`) By default `useDrag` leaves the shared values exactly where the finger lifted. Pass `onRelease` to animate them to a settled position via Inertia's transition vocabulary — spring snap-to-tick, decay with bounds, timing settle. The callback runs as a **worklet** so the release velocity stays on the UI thread (no JS round-trip): ```tsx const drag = useDrag({ axis: 'x', constraints: { left: 0, right: trackWidth }, onRelease: (e) => { 'worklet' // Snap to the nearest tick using the release velocity for natural continuation. const snap = nearestTick(e.x, ticks) return { x: { type: 'spring', to: snap, velocity: e.velocity.x }, } }, }) ``` Return per-axis release transitions in a `ReleaseResult` shape — omit an axis (or return nothing) to leave that SV where it landed. The transition shapes mirror the core `transition` prop: ```ts type ReleaseTransition = | (SpringTransition & { to: number }) | (TimingTransition & { to: number }) | DecayTransition // no `to` — decay decelerates from current position | (NoAnimationTransition & { to: number }) ``` Decay-on-release (for free-flick behaviour without snapping): ```tsx onRelease: (e) => { 'worklet' return { x: { type: 'decay', velocity: e.velocity.x, clamp: [0, trackWidth] }, } } ``` `onRelease` composes additively with `onDragEnd` — both fire on release. Use `onRelease` for the SV animation; use `onDragEnd` for JS-thread side effects (analytics, state updates). Internally this routes through `buildReleaseAnimation` exported from `@rootnative/inertia` — a worklet-safe builder that covers spring / timing / decay / no-animation single-step transitions. Reach for it directly if you're writing a custom `Gesture.Pan().onEnd(...)` worklet outside `useDrag`. ## `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. | ## Scope: touch + mouse only All three hooks layer on `react-native-gesture-handler`, which surfaces touch and mouse events but **not keyboard**. For controls that need keyboard a11y in addition to drag — a Slider with arrow-key increment / decrement, a scrollbar with `PageUp` / `PageDown`, a range input — reach for [`useTouchDrag`](./api/hooks#usetouchdragoptions) from `@rootnative/inertia/touch` instead. It's a PanResponder-backed counterpart to `useDrag` that lives in core (no `react-native-gesture-handler` peer required) and composes cleanly with `onKeyDown` on the wrapping `Pressable`. The full ladder: 1. `Motion.View` + `gesture` prop — pure state-overlay UX (hover, focus, press). 2. `useTouchDrag` from `@rootnative/inertia/touch` — pointer + keyboard drag, no extra peer dep. 3. `useDrag` / `useSwipe` / `usePan` from this package — pointer-only, gesture-handler-backed. Best for projects already using gesture-handler; the UI-thread release path is more precise than PanResponder's JS-thread velocity. 4. Raw `PanResponder` + `useAnimation` — drop here when the gesture lifecycle itself needs customizing (multi-touch composition, custom capture predicates beyond axis lock). Both `useTouchDrag` and the gesture-handler `useDrag` accept the same `onRelease` shape and route through `buildReleaseAnimation`, so swapping between them only touches the wiring, not the release animation config. ## 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 `@rootnative/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 @rootnative/inertia-gradients expo-linear-gradient ``` `expo-linear-gradient` works in bare React Native projects as well as Expo, but it is an Expo Module — bare projects need the Expo modules runtime installed (see [Installing Expo modules](https://docs.expo.dev/bare/installing-expo-modules/)). ## Usage ```tsx import { MotionLinearGradient } from '@rootnative/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 `@rootnative/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. Prebuilt [`MotionCircle` / `MotionRect` / `MotionLine`](#shapes) cover the other common shapes, and the [`createMotionSvgComponent`](#factory) factory behind them wraps any `react-native-svg` element. ## Install ```bash yarn add @rootnative/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 '@rootnative/inertia-svg' function Toggle({ open }) { return ( ) } ``` The package also exports a `MotionSvg` namespace for autocomplete-friendly grouping — `MotionSvg.Path` and `MotionPath` point at the same component, so pick whichever import style matches your codebase: ```tsx import { MotionSvg } from '@rootnative/inertia-svg' ; ``` The namespace also carries the prebuilt shapes: `MotionSvg.Circle`, `MotionSvg.Rect`, and `MotionSvg.Line` — see [Shapes](#shapes) below. 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 the bad target is skipped instead — the path simply keeps its current `d` (a mismatched `initial.d` falls back to the static `d`) so a single bad value 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`. ## Shapes — MotionCircle, MotionRect, MotionLine {#shapes} Prebuilt animatable wrappers for the common non-path shapes, built with the [factory](#factory) below. Each accepts the same `initial` / `animate` / `transition` shape as `MotionPath`, and `transition` also accepts a [named transition](./motion-config.md#named-transitions) registered on the nearest `` — top-level and per-property. | Component | Numeric props | Color props | Array props | | -------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------- | | `MotionCircle` | `cx`, `cy`, `r`, `strokeWidth`, `strokeOpacity`, `fillOpacity`, `opacity`, `strokeDashoffset` | `fill`, `stroke` | `strokeDasharray` | | `MotionRect` | `x`, `y`, `width`, `height`, `rx`, `ry`, `strokeWidth`, `strokeOpacity`, `fillOpacity`, `opacity`, `strokeDashoffset` | `fill`, `stroke` | `strokeDasharray` | | `MotionLine` | `x1`, `y1`, `x2`, `y2`, `strokeWidth`, `strokeOpacity`, `opacity`, `strokeDashoffset` | `stroke` | `strokeDasharray` | The canonical `MotionCircle` consumer is a circular progress ring — a static `strokeDasharray` of the circumference with an animated `strokeDashoffset`: ```tsx import Svg from 'react-native-svg' import { MotionCircle } from '@rootnative/inertia-svg' const CIRCUMFERENCE = 2 * Math.PI * 45 function ProgressRing({ progress }: { progress: number }) { return ( ) } ``` Shape-specific rules (they mirror `MotionPath`'s command-sequence lock): - **Array props lock their length at first render.** `strokeDasharray` interpolates element-wise, so a target with a different length throws in dev and is ignored in production. Remount with a new `key` to change the length. - **A key only animates when it's present at mount** — in the static props, `initial`, or `animate`. Keys introduced into `animate` after mount warn in dev and are ignored. Keys never mentioned pass through as ordinary static props, so un-animated attributes keep their element defaults. - Numeric keys engaged only via `animate` seed from `0`; colors seed from `'transparent'`. Give the key a static prop or an `initial` value when the mount animation should start elsewhere. ## createMotionSvgComponent {#factory} The factory behind the prebuilt shapes. Point it at any `react-native-svg` element and declare which props animate and how each interpolates: ```tsx import { Ellipse } from 'react-native-svg' import { createMotionSvgComponent } from '@rootnative/inertia-svg' const MotionEllipse = createMotionSvgComponent(Ellipse, { animatableProps: ['cx', 'cy', 'rx', 'ry', 'opacity'], // numeric colorProps: ['fill', 'stroke'], // color strings arrayProps: ['strokeDasharray'], // numeric arrays, length locked at mount }) ``` Prop keys are inferred from the wrapped component, so a typo in `animatableProps` is a compile error, and the generated component's `animate` / `initial` only accept the declared keys. Everything documented for the shapes above — named transitions, per-property transitions, `initial` semantics, reduced motion, the mount locks — comes from the factory and applies to any component it builds. `MotionPath` remains hand-rolled: path morphing needs the `d` template machinery the generic factory doesn't model. ## What this package doesn't do - **Path resampling** between structurally different shapes. Same-template morphs only — the rest is your `key` to remount. - **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 '@rootnative/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 '@rootnative/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 a JS-thread map, and waits for each to call `safeToRemove()` after finishing its exit animation before unmounting. It deliberately does **not** use Reanimated's `entering` / `exiting` lifecycle — the exit frames are ordinary Motion animations driven by the child's `exit` prop, which is what lets an interrupted exit reverse smoothly on re-entry. ## 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. Keyless children are always skipped (not rendered); dev builds additionally log a warning, production builds drop them silently. ## 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 '@rootnative/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 '@rootnative/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 ``` Custom easing functions must be worklets, same as `transition.easing` — add the `'worklet'` directive as the function's first statement. Plain functions warn in dev and only work on web. ## 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. ## Shared element transitions (`layoutId`) The `layoutId` prop is a separate but related mechanism for transitioning a logical element between two screens or two layouts. ```tsx // Screen A // Screen B (after navigation) ``` When the first `Motion.View` unmounts and a second `Motion.View` with the same `layoutId` mounts within ~1 second, the new element FLIPs into place from the previous element's last measured rect — a Hero-style transition without any explicit animation config beyond the shared id. How it differs from `layout`: - `layout` animates **this** element's own size/position changes between commits (no id needed). - `layoutId` animates from **another** element's last rect to this element's current rect (cross-mount or cross-screen). The **incoming** element's `transition` prop controls the FLIP animation (spring by default; `'timing'` honored; `'decay'` downgrades to spring; reduced motion skips the transition) — the outgoing element contributes only its last rect. Note it must be a top-level transition object (`transition={{ type: 'timing', duration: 250 }}`); a per-property map is ignored for the FLIP and the default spring applies. ### Caveats and current scope - **Rect-only.** v1 animates the four FLIP transforms (`translateX/Y`, `scaleX/Y`) only. Other style props — `borderRadius`, `backgroundColor`, `padding` — snap. Style-prop interpolation lands in v2. - **Parent-relative coordinates.** Rects come from `onLayout`, which reports parent-relative coords. For the common case where source and target screens share an outer container (e.g. React Navigation's content area), the deltas match what the user perceives. Nested-parent setups where source and target screens sit under containers at different screen offsets will be off by that offset. - **One mount per id.** Two `Motion.*` primitives with the same `layoutId` mounted simultaneously is undefined behavior. The most-recent layout commit wins as the source for the next consumer. - **TTL.** Released rects are consumable for ~1 second. If the new mount lands later (a slow navigation, a paused transition), no FLIP runs and the element just appears at its natural position — graceful degradation. ### Why not `sharedTransitionTag`? Reanimated 4 removed `sharedTransitionTag` / `SharedTransition` entirely; `layoutId` is the Inertia-side replacement. It's a JS-side measure registry rather than a native handoff, so it doesn't require a native screen-transition harness — it works with any navigator (React Navigation, Expo Router, hand-rolled) that mounts and unmounts screens. ## What `layout` doesn't do (yet) - **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 for subtree-wide animation config: how descendant Motion primitives respond to the OS reduce-motion accessibility setting, and a registry of [named transitions](#named-transitions) usable anywhere a transition config is accepted. :::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 reads 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 '@rootnative/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 ``` Nested providers **inherit** from their ancestor: a provider that omits `reducedMotion` keeps the ancestor's mode, and `transitions` maps merge per name (child wins). A transitions-only provider in the middle of the tree never disturbs an outer `reducedMotion` override. ## Named transitions `transitions` registers named transition configs for the subtree. Every place that accepts a `TransitionConfig` also accepts a registered name: the `transition` prop (top-level **and** per-property / per gesture layer), the `layout` prop, and the value-layer hooks (`useAnimation`, `useSpring`, `useBooleanSpring`, `useGesture`, `useGestureLayer`). ```tsx {/* Anywhere below the provider: */} ``` ```ts const progress = useBooleanSpring(checked, 'selection') const fade = useAnimation(visible ? 1 : 0, 'state-hover') ``` The names are **your** vocabulary — design-system tokens, semantic roles, whatever your app standardizes on. Inertia ships no presets; the registry is the mechanism that lets a theme file (MD3, iOS-style, Fluent, your own) be the single source of truth for motion values instead of copy-pasted config objects at every call site. Semantics: - **Nearest provider wins.** Names resolve against the merged registry of all ancestor `` providers. - **Nested providers merge.** A child provider's `transitions` map merges into the ancestor registry; same-named entries are overridden by the child. - **Unknown names degrade softly.** An unregistered name warns in development and falls back to the library default spring — the animation still runs. - **Spring-only hooks stay spring-only.** `useSpring` / `useBooleanSpring` accept names, but a name registered as `timing` / `decay` / `no-animation` warns in dev and falls back to the default spring. Use `useAnimation` when the named transition's type should be honored. ### Typed names (optional) Out of the box, any string is accepted where a name is (`TransitionName` is `string`). To get autocomplete and compile-time typo checking, augment the `RegisteredTransitions` interface with your registry's keys: ```ts // motion.d.ts (anywhere in your app's include path) import type { TransitionConfig } from '@rootnative/inertia' declare module '@rootnative/inertia' { interface RegisteredTransitions { 'state-hover': TransitionConfig 'state-press': TransitionConfig selection: TransitionConfig } } ``` With the augmentation in place, `transition="selectoin"` is a compile error. The value type is ignored — only the keys matter. ### Consuming the registry from custom components Adapter packages and custom animated components can support names on their own surface with `useNamedTransitions()` + `resolveNamedTransition()`: ```ts import { resolveNamedTransition, useNamedTransitions, } from '@rootnative/inertia' function useMyAnimatedThing(transition?: TransitionConfig | TransitionName) { const registry = useNamedTransitions() const config = resolveNamedTransition(transition, registry) // config is a concrete TransitionConfig | undefined } ``` ## Reading the resolved value Two hooks are available for components that want to react to the active mode: ```tsx import { useMotionConfig, useShouldReduceMotion } from '@rootnative/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. In `'user'` mode it reads the OS setting via Reanimated's `useReducedMotion()`, which captures the value **once at app start** — toggling the accessibility setting while the app is running takes effect on the next launch, not immediately. (`'never'` and `'always'` don't consult the OS at all, and changing the `reducedMotion` prop itself re-renders normally.) --- # 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/rootnative/inertia/blob/main/example/screens/PerfBenchScreen.tsx) and run a custom dev client (`pnpm --filter @rootnative/inertia-example exec expo run: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 @rootnative/inertia-example exec expo run:android --variant release` (the package's `android` script only starts the dev server — a release build needs `expo run:android`). 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 `baseSig` / `transitionSig`. Re-check the [memoization regression test](https://github.com/rootnative/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. :::tip Asserting post-animation styles? Use `renderWithMotion`. Plain `render(...)` returns `initial` styles only — the mock doesn't flush effects synchronously. If your tests appear to "see" pre-animation state and you've been writing comments like _"don't assert post-interaction styles, the mock is static-render"_, that's the wrong workaround. Swap `render` for `renderWithMotion` and the rendered tree settles on the `animate` target. ```ts import { renderWithMotion } from '@rootnative/inertia/testing' const { getByTestId } = renderWithMotion() // getByTestId('card').props.style now reflects the animate target, // not the initial seed. ``` For prop changes after mount (a `useState` toggling `animate`, a controller transition), pair with `flushMotion(result, nextUi)` — see [below](#flushmotionresult-nextui). ::: ## Setup Inertia ships a Jest preset that wires everything up — the Reanimated mock, the `react-native-worklets` stub, the `Easing.bezier()` factory shape, and the `transformIgnorePatterns` widening needed to let Jest transform `@rootnative/inertia*`'s published ESM bundles. Point Jest at it with one line: ```js // jest.config.js module.exports = { preset: require.resolve('@rootnative/inertia/jest-preset'), } ``` The preset internally extends `react-native`'s own preset, so you don't need to reference both. If you already have setup files or `transformIgnorePatterns` of your own, extend the preset: ```js const inertia = require('@rootnative/inertia/jest-preset') module.exports = { ...inertia, setupFiles: [...inertia.setupFiles, '/my-setup.js'], transformIgnorePatterns: [ 'node_modules/(?!(react-native|@react-native|@react-native-community|@rootnative/inertia|@rootnative/inertia-gestures|@rootnative/inertia-gradients|@rootnative/inertia-svg|react-native-worklets|my-other-esm-pkg)/)', ], } ``` If you can't use the preset (e.g. you have a custom transform pipeline that conflicts), add the setup file directly: ```js module.exports = { preset: 'react-native', setupFiles: [require.resolve('@rootnative/inertia/jest-setup')], transformIgnorePatterns: [ 'node_modules/(?!(react-native|@react-native|@react-native-community|@rootnative/inertia|@rootnative/inertia-gestures|@rootnative/inertia-gradients|@rootnative/inertia-svg|react-native-worklets)/)', ], } ``` 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 '@rootnative/inertia/testing' import { Motion } from '@rootnative/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/rootnative/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 '@rootnative/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 `@rootnative/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 `@rootnative/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 '@rootnative/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 Every `Motion.*` primitive **throws in dev** when `style` is a function, rather than let Reanimated's `createAnimatedComponent` wrapper silently drop 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 '@rootnative/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 '@rootnative/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 are covered by the [`@rootnative/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 '@rootnative/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 '@rootnative/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 '@rootnative/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. - **Style-prop-interpolating shared elements** — the [`layout` prop and `layoutId`](../layout) cover auto-layout and rect-only hero transitions, but interpolating `borderRadius` / colors between shared elements (and window-coordinate measurement across differently-offset parents) is v2 scope. For those cases, raw Reanimated is still the tool. - **Slider / continuous gesture range UI with exotic requirements** — [`useDrag`](../gestures-adapter) and [`useTouchDrag`](../api/hooks#usetouchdragoptions) cover the standard drag/slider shapes; keep the hand-rolled PanResponder + `useSharedValue` flow only when you need custom gesture arbitration those hooks don't express. 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 `@rootnative/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`, `useBooleanSpring`, `useTransform`, `useShadow`, `useColorTransition`, `useScroll`) compose with `useAnimatedStyle` and every other Reanimated primitive — they return real shared values (or, in `useShadow` / `useColorTransition`'s case, an animated style fragment), 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 '@rootnative/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 '@rootnative/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 \| TransitionName)` | `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. `config` also accepts a [named transition](../motion-config#named-transitions). Because the hook is spring-only, the name must be registered as a spring — a timing / decay / no-animation name warns in dev and falls back to the default spring (use `useAnimation` to honor the registered type). The same applies to `useBooleanSpring`. ## `useBooleanSpring(active, config?)` Sugar over `useSpring` for the recurring "spring 0↔1 progress from a boolean" shape — checkbox checks, accordion expansions, drawer open/closed states, focus rings, and every other binary UI flip that wants spring physics rather than a hard cut. ```tsx import { useBooleanSpring, useColorTransition, useShadow, } from '@rootnative/inertia' function Card({ raised }: { raised: boolean }) { // One driver, multiple downstream styles. The same progress feeds the // shadow tween and the surface-color shift in lockstep. const progress = useBooleanSpring(raised, { tension: 180, friction: 18 }) const shadowStyle = useShadow({ from: REST_SHADOW, to: RAISED_SHADOW, progress, }) const fillStyle = useColorTransition(progress, ['#ffffff', '#f8fafc']) return } ``` The returned shared value sits at `0` when `active` is `false` and animates toward `1` when it flips to `true` (and back on the reverse flip). The spring re-runs whenever `active` changes. | Signature | Returns | | -------------------------------------------------------------------------------- | --------------------- | | `useBooleanSpring(active: boolean, config?: SpringTransition \| TransitionName)` | `SharedValue` | Config follows the same react-spring vocabulary as `useSpring` (`tension` / `friction` / `mass`); omit it for the library defaults. Note that like `useSpring`, this hook does **not** consult `` — it always interpolates. If the value must respect reduced motion, drive it through `useAnimation` (which gates on the config) or gate the target yourself with `useShouldReduceMotion()`. **`useBooleanSpring` vs `useSpring(active ? 1 : 0)`.** Identical mechanics — `useBooleanSpring` is the named version of the pattern so the call site reads as the boolean it represents, not as a ternary. Reach for it whenever the source is a boolean prop and the target is a 0↔1 progress value to feed into `useShadow` / `useColorTransition` / `useTransform` / a hand-rolled `useAnimatedStyle`. **`useBooleanSpring` vs `useAnimation(active ? 1 : 0, { type: 'spring', ... })`.** Same animation either way; `useBooleanSpring` is the shorter spelling for the spring-only case. Drop to `useAnimation` if you need a `timing` curve, a `decay`, or a `repeat`. ## `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 '@rootnative/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 — add the `'worklet'` directive as its first statement so your Babel plugin captures the shared values it reads as the worklet's closure. A plain function warns in dev and cannot track dependencies: the fallback wrapper closes over the opaque function reference, not the shared values read inside it, so the derived value only refreshes on React re-renders (and native builds reject it outright). The transformer must be pure: no captured JS-thread refs, no calls to non-worklet APIs. ## `useShadow({ from, to, progress })` Interpolate between two shadow configs as `progress` moves 0→1. Returns an animated style fragment you spread onto any Reanimated-aware view (`Motion.*` or a hand-rolled `Animated.View`) — no other props required on the host. ```tsx import { Motion, useShadow, useSpring } from '@rootnative/inertia' function ElevatedCard({ raised }: { raised: boolean }) { // Whatever drives `progress` is your concern — a spring, a scroll-derived // useTransform, a gesture progress value. The hook is a pure interpolator; // it does not animate on its own. const progress = useSpring(raised ? 1 : 0, { tension: 180, friction: 18 }) const shadowStyle = useShadow({ from: { shadowOpacity: 0.08, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1, }, to: { shadowOpacity: 0.24, shadowRadius: 16, shadowOffset: { width: 0, height: 10 }, elevation: 10, }, progress, }) return } ``` `ShadowConfig` accepts the same flat shadow keys as `Motion.View`'s `animate` surface, plus the nested `shadowOffset` source: | Field | Type | Notes | | --------------- | ------------------------------------- | ---------------------------------------------------------- | | `shadowOpacity` | `number` | Standard iOS shadow opacity. | | `shadowRadius` | `number` | Blur radius in points. | | `shadowOffset` | `{ width?: number; height?: number }` | Single-value form — width/height each tween independently. | | `elevation` | `number` | Android elevation. iOS-only consumers can leave it off. | | `shadowColor` | `string` | Color string. Interpolated via `interpolateColor`. | Only keys present on at least one side appear in the output style — there's no overhead for keys you don't use. A key present on one side and absent from the other tweens from / to the absent side's natural zero (`0`, `'transparent'`, or `{ width: 0, height: 0 }`). | Signature | Returns | | ------------------------------------------------------------------------------------ | ------------------------------------- | | `useShadow({ from: ShadowConfig; to: ShadowConfig; progress: SharedValue })` | Animated style (spread onto `style`). | **`useShadow` vs the declarative `animate` path.** If a state change in your component already swaps between two shadow configs (`animate={raised ? RAISED : REST}`), the `animate` prop is simpler — no hook needed. Reach for `useShadow` when the source of progress isn't a render-driven state swap: scroll position, drag progress, a `useSpring` driven by a shared value, or anywhere else the value-layer is the right abstraction. The example screen in the gallery drives it from a `useSpring`; wiring it to `useScroll` + `useTransform` for a scroll-driven elevation cascade is a one-line change. ## `useColorTransition(progress, [from, to], options?)` Interpolate a single color channel between `from` and `to` as `progress` moves 0→1. Returns an animated style fragment with one color key — spread it onto any Reanimated-aware view alongside other style fragments (a `useShadow` result, a hand-rolled `useAnimatedStyle`, plain `StyleSheet` entries). ```tsx import { Motion, useBooleanSpring, useColorTransition, } from '@rootnative/inertia' function Chip({ active }: { active: boolean }) { // Drive the tween from a boolean spring. The same progress feeds two // independent color channels — fill and ring shift in lockstep. const progress = useBooleanSpring(active, { tension: 200, friction: 18 }) const fillStyle = useColorTransition(progress, ['#e5e7eb', '#4f46e5']) const ringStyle = useColorTransition(progress, ['#d1d5db', '#312e81'], { key: 'borderColor', }) return } ``` The hook is a pure interpolator; it does not animate on its own. Drive `progress` upstream with a `useSpring`, `useBooleanSpring`, scroll-derived `useTransform`, gesture progress, or anything else producing a 0↔1 shared value. Values outside `[0, 1]` clamp. `options.key` chooses which RN style slot the interpolated color is emitted under: | Key | Default | Notes | | ----------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------ | | `backgroundColor` | ✓ | State-layer haloes, card fills, chip surfaces. | | `color` | | Animate text color (combine with `Motion.Text` or a plain text). | | `borderColor` / `borderTopColor` / `borderRightColor` / `borderBottomColor` / `borderLeftColor` | | Focus rings, outline transitions. | | `tintColor` | | `Motion.Image` tint. | | `shadowColor` | | Shadow color shifts. (For full shadow tweens, prefer `useShadow`.) | | Signature | Returns | | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | | `useColorTransition(progress: SharedValue, range: readonly [string, string], options?: UseColorTransitionOptions)` | Animated style (spread onto `style`). | **`useColorTransition` vs `useTransform` with a color output range.** `useTransform(progress, [0, 1], [from, to])` returns a raw `SharedValue` — reach for it when you need to compose the color into a hand-rolled `useAnimatedStyle` (mixing with a `Math.max(...)` halo opacity, feeding into a gradient, etc.). `useColorTransition` is the one-call convenience for the dominant case: tween a single color slot and spread the result onto `style`. **`useColorTransition` vs the declarative `animate` path.** If a state change already swaps between two colors (`animate={active ? { backgroundColor: A } : { backgroundColor: B }}`), the `animate` prop is simpler. Reach for `useColorTransition` when the source of progress isn't a render-driven state swap, or when several color slots need to shift in lockstep from one shared driver (the chip example above — one spring, three color channels). ## `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 '@rootnative/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 \| TransitionName)` | `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. `transition` also accepts a [named transition](../motion-config#named-transitions); unlike the spring-only hooks, the registered config's type is honored whatever it is. **`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 '@rootnative/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 '@rootnative/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. - A [named transition](../motion-config#named-transitions) is accepted in both positions: `useGesture('state-press')` or `useGesture({ pressed: 'state-press' })`. - 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. ## `useGestureLayer(states, options?)` A higher-level helper over `useGesture` for the **strongest-active-layer-wins** composition model used by MD3 state-layer haloes and iOS-translucent overlays. You supply per-state target maps; the hook owns the four gesture progress shared values, the `disabled` override, the worklet, and the transition. It also hands those progress shared values back as `states`, so styles derived from the same gesture wiring (an elevation crossfade, an icon tint) don't need a second `useGesture` call. Lives at the `@rootnative/inertia/gesture-layer` subpath so apps that don't need it don't pay for it. ```tsx import { Pressable } from 'react-native' import Animated from 'react-native-reanimated' import { useGestureLayer } from '@rootnative/inertia/gesture-layer' function SwitchHalo({ disabled }: { disabled?: boolean }) { const { style, handlers } = useGestureLayer( { rest: { opacity: 0, backgroundColor: 'transparent' }, hovered: { opacity: 0.08, backgroundColor: '#000' }, focused: { opacity: 0.1, backgroundColor: '#000' }, pressed: { opacity: 0.12, backgroundColor: '#000' }, }, { disabled, transition: { type: 'timing', duration: 150 } }, ) return ( ) } ``` ### Composition model - **Numeric keys** (`opacity`, `scale`, `borderWidth`, …) compose via clamped-max with `rest` as the floor: `out = max(rest, …for each active layer: lerp(rest, layer, progress))`. Multiple states active simultaneously raise the value to the strongest layer, not the sum — this is the MD3 halo pattern. - **Color keys** (any string value) compose via priority cascade with `interpolateColor`, lowest priority first: `hovered → focused → focusVisible → pressed`. Clamped-max doesn't apply to colors; this matches the cascade used by the declarative `gesture` prop. - **Disabled** sits at the top of the cascade for both numeric and color keys — when active, it lerps the composed value toward the `disabled` target. The `disabled` flag is JS-side, not gesture-driven. ### States | Key | Notes | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `rest` | Base values, applied when no other layer is active. Missing keys default to `0` (numeric) / `'transparent'` (color). | | `hovered` | Web-only hover. No-op on native. | | `focused` | Any focus modality (mouse, touch, keyboard). | | `focusVisible` | Keyboard-only focus (W3C `:focus-visible`). On native, behaves identically to `focused`. | | `pressed` | Active touch / pointer-down. | | `disabled` | Gated by `options.disabled`. Overrides every gesture layer when active; per-layer transitions don't apply (top-level transition or default spring). | Every state key is optional. Values inside each state are a flat map of style keys to either a number (numeric layer) or a string (color layer). The hook doesn't validate that string values are valid colors — passing `borderStyle: 'solid'` will crash inside the worklet. Keep string values to color strings. ### Options | Field | Type | Notes | | ------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `disabled` | `boolean` | Activates the `disabled` layer (or `rest` if `disabled` is undefined). | | `transition` | `TransitionConfig \| TransitionName \| GestureLayerTransitions` | Forwarded to the underlying `useGesture`. Either one config for every gesture layer, or per-layer (`{ pressed, focused, focusVisible, hovered }`). Reduced motion collapses every transition to `'no-animation'`. Per-layer transitions don't apply to `disabled` — it uses the top-level config (or the default spring). | ### Returns | Field | Type | Notes | | ---------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `style` | Animated style | Spread onto an `Animated.View` or a `Motion.*`'s `style`. | | `handlers` | `UseGestureHandlers` | `{ onPressIn, onPressOut, onHoverIn, onHoverOut, onFocus, onBlur }`. Spread on the host `Pressable`. | | `states` | `GestureLayerProgress` | The per-state 0↔1 progress shared values the style is derived from: `{ hovered, focused, focusVisible, pressed, disabled }`. Stable across renders; treat as read-only — the handlers own the writes. | `states` exists so a component that already wires its state layer through `useGestureLayer` can derive _additional_ styles from the same gesture progress — without duplicating the gesture wiring through a parallel `useGesture` call. The canonical case is an elevation crossfade on hover: ```tsx import { useGestureLayer } from '@rootnative/inertia/gesture-layer' import { useShadow } from '@rootnative/inertia' const { style, handlers, states } = useGestureLayer(layerStates, { disabled }) // Feed the raw hover progress straight into another progress-driven hook: const shadow = useShadow({ from: restShadow, to: raisedShadow, progress: states.hovered, }) // ``` The gesture entries (`hovered` / `focused` / `focusVisible` / `pressed`) are the same shared values `useGesture` returns; `disabled` is the hook's own 0↔1 progress for the `options.disabled` override. Any of them also plug into a custom `useAnimatedStyle` or `useDerivedValue` for derivations the composed style can't express. **`useGestureLayer` vs the declarative `gesture` prop.** The prop composites states as a fixed **priority cascade** (`hovered → focused → focusVisible → pressed`) — when two layers are fully active, the value converges to the higher-priority layer's target, regardless of which is numerically stronger. `useGestureLayer` composites numerics via **clamped-max** — pressing while hovered shows whichever target is _stronger_ per-key, whatever its priority. The MD3 / iOS-translucent halo wants clamped-max so a weaker press target can't visually "dim" an active hover layer. Reach for the prop for ordinary button feedback (`{ scale: 0.96 }` on press layering over an opacity hover); reach for this hook when targets are state-layer overlays whose strongest value should win. **`useGestureLayer` vs `useGesture` + custom worklet.** `useGesture` returns the raw 0↔1 progress shared values. Use it for compositions this hook doesn't express — additive blends, per-key custom rules, multi-target outputs (e.g. a halo plus a separate ring that uses different math). `useGestureLayer` is the convenience layer for the one composition model it owns. ## `useVariants(variants, initial?)` Build a controller for a variants map. Pass it through `controller={...}` to drive transitions imperatively. ```ts import { useVariants } from '@rootnative/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 '@rootnative/inertia' const { reducedMotion, transitions } = useMotionConfig() // reducedMotion: 'user' | 'never' | 'always' // transitions: the merged named-transition registry from ancestor providers ``` Returns the default (`{ reducedMotion: 'user', transitions: {} }`) 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 '@rootnative/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 '@rootnative/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. ## `buildReleaseAnimation(transition, toValue)` Worklet-safe single-step animation builder. Mirrors a subset of [`resolveTransition`](./transition-utilities.md#resolvetransitionconfig-tovalue-callback) for the UI-thread path where the transition config is picked at gesture-release time, not at render time. Supports `spring` / `timing` / `decay` / `no-animation`; sequences, top-level `repeat`, and easing-function auto-worklet-wrapping are not. ```ts import { buildReleaseAnimation } from '@rootnative/inertia' import { Gesture } from 'react-native-gesture-handler' const pan = Gesture.Pan().onEnd((e) => { 'worklet' // Spring to a snap target with the release velocity preserved. position.value = buildReleaseAnimation( { type: 'spring', velocity: e.velocityX, friction: 26, tension: 170 }, snapTarget, ) as number }) ``` For decay transitions, the second argument is ignored — decay decelerates from the SV's current position via its own physics. | Signature | Returns | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `buildReleaseAnimation(transition: TransitionConfig, toValue: number)` | Reanimated animation value (assign to a `SharedValue` directly). | Most consumers reach for `useDrag({ onRelease })` from `@rootnative/inertia-gestures` instead — it wraps this builder behind a per-axis return shape. Use `buildReleaseAnimation` directly when you're authoring a custom `Gesture.*().onEnd(...)` worklet outside the adapter hooks. ## `useTouchDrag(options?)` PanResponder-backed drag hook. The keyboard-a11y and zero-extra-peer-dep counterpart to `useDrag` from `@rootnative/inertia-gestures` — both share the same `onRelease` shape, but `useTouchDrag` lives in core (`@rootnative/inertia/touch`) and returns `panHandlers` to spread on a `Pressable` / `View` instead of a `gesture` object for ``. ```tsx import { Motion } from '@rootnative/inertia' import { useTouchDrag } from '@rootnative/inertia/touch' import { Pressable } from 'react-native' function Slider({ ticks }: { ticks: number[] }) { const drag = useTouchDrag({ axis: 'x', constraints: { left: 0, right: 280 }, onRelease: (e) => { const snap = nearestTick(e.x, ticks) return { x: { type: 'spring', to: snap, velocity: e.velocity.x } } }, }) 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. | | `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. Velocity normalized to px/sec. | | `onRelease` | `(info) => ReleaseResult \| void` | none | JS-thread release transition. Same shape as the gesture-handler `useDrag`'s `onRelease`. | Returns `{ panHandlers, animatedStyle, dragX, dragY, isDragging }`. **When to pick `useTouchDrag` over `useDrag` from `@rootnative/inertia-gestures`:** you need keyboard a11y on the dragged element (arrow keys, `PageUp` / `PageDown`), OR you don't want to take `react-native-gesture-handler` as a peer dep. **When to skip it:** you're already using `react-native-gesture-handler` elsewhere — the gesture-handler `useDrag`'s release velocity is UI-thread and slightly more precise; consistency across the app trumps a small per-call win. --- # 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 '@rootnative/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 '@rootnative/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. --- # Transition utilities The JS-thread resolver functions the Motion primitives are built on, exported for anyone building their own animated component on top of Inertia's transition vocabulary. This is exactly how the adapter packages work — `MotionLinearGradient` and `MotionPath` are external consumers of these same functions. Reach for this layer when a component animates something the core primitives can't reach (SVG props, gradient stops, any non-`style` target) but you still want it to accept the standard `transition={{ type: 'spring', tension: 180 }}` shape instead of inventing your own config. All of them run on the **JS thread** — call them from render, effects, or event handlers, and assign the result to a shared value. The baked animation executes on the UI thread; the resolution itself never happens at frame time. (For picking a transition inside a gesture-release **worklet**, see [`buildReleaseAnimation`](./hooks.md#buildreleaseanimationtransition-tovalue) — that's the UI-thread counterpart to `resolveTransition`.) ## `resolveTransition(config, toValue, callback?)` Build a baked Reanimated animation for a single property from a `TransitionConfig` and a target value. Handles all four transition types plus `delay` / `repeat` wrapping: | `config.type` | Produces | | ---------------- | ----------------------------------------------------------------------------- | | `'spring'` | `withSpring` (react-spring vocabulary converted to Reanimated under the hood) | | `'timing'` | `withTiming` (easing auto-worklet-wrapped, see below) | | `'decay'` | `withDecay` — `toValue` is ignored; decay decelerates via its own physics | | `'no-animation'` | the raw `toValue` (direct assignment, no interpolation) | `config` may be `undefined` — the default spring applies, same as the primitives. ```ts import { resolveTransition, type TransitionConfig } from '@rootnative/inertia' import { useSharedValue } from 'react-native-reanimated' function useGlow(active: boolean, transition?: TransitionConfig) { const intensity = useSharedValue(0) useEffect(() => { // JS thread: bake the animation once per change… intensity.value = resolveTransition(transition, active ? 1 : 0) as number // …the UI thread just plays it. }, [active, transition]) return intensity } ``` The return type is `unknown` because Reanimated's animation objects aren't part of Inertia's public type surface — cast to the shared value's type at the assignment site (`as number` / `as string`), the same way the adapter packages do. Color targets work: pass a color string as `toValue` and Reanimated's value setter interpolates the packed RGBA representations natively (seed the shared value with a color string so the slot is recognized as a color from the first frame). **`callback`** fires when the animation settles. It runs on the **UI thread**, so it must be a worklet — either author one with the `'worklet'` directive or bridge with `runOnJS`: ```ts import { runOnJS } from 'react-native-reanimated' sv.value = resolveTransition( { type: 'timing', duration: 200 }, 1, (finished) => { 'worklet' if (finished) runOnJS(onSettled)() }, ) as number ``` For `'no-animation'` the callback fires synchronously on the JS thread (there's nothing to wait for). Repeat-wrapped animations forward the callback to `withRepeat`, so it fires once per iteration — Reanimated's own behavior. ## `resolveAnimatableValue(value, base, factory?)` The level above `resolveTransition`: resolve a full per-property `animate` value — including sequences and per-step overrides — into one animation. This is what gives a custom component the complete `animate` grammar, not just single targets. It accepts the three shapes of `AnimatableValue`: | Shape | Example | Resolution | | -------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | plain value | `100` | single `resolveTransition` call | | `{ to, ...override }` step | `{ to: 100, type: 'timing' }` | single step, override merged into `base` | | array of either | `[0, { to: 100, type: 'spring' }, 0]` | `withSequence` of resolved steps; the top-level `repeat` applies at the **sequence level**, per-step `repeat` stays local | ```ts import { resolveAnimatableValue, type AnimatableValue, type TransitionConfig, } from '@rootnative/inertia' function usePulse( value: AnimatableValue, transition?: TransitionConfig, ) { const sv = useSharedValue(0) useEffect(() => { // Accepts 1, { to: 1, delay: 200 }, or [0, 1, 0] equally. sv.value = resolveAnimatableValue(value, transition) as number }, [value, transition]) return sv } ``` Override merging follows the same rule as the primitives: if a step's override declares a different `type` than `base`, the override wins outright — spring fields never bleed into a timing step. The optional **`factory`** argument produces per-step completion callbacks. It's called with `(phase, step)` — `('step', i)` for each sequence step, `('animation', undefined)` for non-sequences — and returns a worklet callback (or `undefined` to skip). This is the mechanism behind the primitives' `onAnimationEnd` firing once per step; supply it only if your component surfaces step-level completion. ## `ensureWorkletEasing(easing)` Normalize any accepted easing input into a worklet function `withTiming` can consume. Three inputs, one output: - **Already a worklet** (a Reanimated `Easing.*` curve, or a user function processed by the worklets Babel plugin) → returned as-is. - **Reanimated 4 `EasingFunctionFactory`** (what `Easing.bezier(...)` returns since v4) → unwrapped via `.factory()` automatically, so nobody has to know that API changed. - **Plain JS function** → wrapped in a `'worklet'`-directive closure so Reanimated 3.9+'s nested-transition validation doesn't crash with `[Reanimated] The easing function is not a worklet`. `undefined` passes through as `undefined` (callers apply their own default). ```ts import { ensureWorkletEasing } from '@rootnative/inertia' import { Easing, withTiming } from 'react-native-reanimated' // All three normalize correctly: ensureWorkletEasing((t) => t * t) // plain fn → worklet-wrapped ensureWorkletEasing(Easing.bezier(0.33, 1, 0.68, 1)) // factory → unwrapped ensureWorkletEasing(Easing.inOut(Easing.ease)) // worklet → as-is sv.value = withTiming(1, { duration: 300, easing: ensureWorkletEasing(userEasing) ?? Easing.inOut(Easing.ease), }) ``` You only need this when calling `withTiming` yourself with user-supplied easing. `resolveTransition` and `resolveAnimatableValue` already run every `easing` through it, as do all the Motion primitives. The wrapped function must be **pure** — no JS-thread captured refs, no shared mutable state, no calls to non-worklet APIs. The wrapper carries the user function across the worklet boundary via closure; anything impure it closes over will be stale or crash on the UI thread. ## `cubicBezier(x1, y1, x2, y2)` / `cubicBezier(css)` Build a `timing.easing` value from cubic-bezier control points — four numbers, a W3C CSS `cubic-bezier(...)` string, or a CSS easing keyword (`'linear'`, `'ease'`, `'ease-in'`, `'ease-out'`, `'ease-in-out'`): ```ts import { cubicBezier } from '@rootnative/inertia' cubicBezier(0.2, 0, 0, 1) cubicBezier('cubic-bezier(0.2, 0, 0, 1)') // design-token form cubicBezier('ease-out') ``` Returns exactly what `timing.easing` accepts (Reanimated's bezier factory — `ensureWorkletEasing` handles it like every other easing input), so it works in the `transition` prop, in [named transitions](../motion-config#named-transitions), and anywhere else this page's resolvers take a config. Invalid input throws: `x1` / `x2` must be finite and within `[0, 1]`, and unsupported tokens (`step-start`, `linear(...)`) are rejected rather than approximated. See [Transitions → cubicBezier](../transitions#cubicbezier--css-easing-tokens) for the token-file workflow this enables. ## `resolveNamedTransition(input, registry)` Resolve a `TransitionConfig | TransitionName` input into a concrete config. A config object passes through untouched; a name is looked up in `registry` — unknown names warn in dev and fall back to the library default spring. Pair it with `useNamedTransitions()` (which reads the merged registry off the nearest [``](../motion-config#named-transitions)) to make a custom component accept registered names wherever it accepts a config: ```tsx import { resolveNamedTransition, resolveTransition, useNamedTransitions, type TransitionInput, } from '@rootnative/inertia' function useMyAnimatedProp(target: number, transition?: TransitionInput) { const registry = useNamedTransitions() const config = resolveNamedTransition(transition, registry) // config: TransitionConfig | undefined — feed it to resolveTransition sv.value = resolveTransition(config, target) } ``` This is how the Motion primitives and value-layer hooks support `transition="selection"` internally; adapter packages use the same two calls to join the registry. ## Which layer to use | You're writing… | Use | | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | a component with declarative `animate` props over a `style` target | [`createMotionComponent`](./create-motion-component.md) — no resolver calls needed | | a component animating non-`style` props (SVG, gradients, text runs) | `resolveAnimatableValue` (full grammar) or `resolveTransition` (single targets) | | a gesture-release worklet picking a transition on the UI thread | [`buildReleaseAnimation`](./hooks.md#buildreleaseanimationtransition-tovalue) | | a manual `withTiming` call with consumer-supplied easing | `ensureWorkletEasing` |