One Logo Pool Ball
wwwwwwwwwwwwwwwwwww

Gestures and Animation

Use Gesture Handler and Reanimated in a One native route

One native routes run the standard animation stack: React Native Gesture Handler 3 for gestures, Reanimated 4 for UI-thread animation, and Worklets for the runtime underneath. Gesture callbacks that update shared values run on the UI runtime; One’s native compiler applies the Worklets transform automatically, so no Babel plugin configuration is needed. One exposes no alternate gesture or animation facade: these remain independent package APIs.

Add all three packages, then rebuild the native app so their native modules link:

Terminal window
bun add react-native-gesture-handler@~3.3.0 react-native-reanimated@~4.7.1 react-native-worklets@~0.13.0
bun run prebuild:native
bun run ios

Usage

import { useState } from 'react'
import { Text } from 'react-native'
import {
GestureDetector,
GestureHandlerRootView,
usePanGesture,
} from 'react-native-gesture-handler'
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import { scheduleOnRN } from 'react-native-worklets'
export default function DragRoute() {
const [distance, setDistance] = useState(0)
const x = useSharedValue(0)
const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }))
const pan = usePanGesture({
onUpdate: (event) => {
x.value = event.translationX
},
onDeactivate: (event) => {
scheduleOnRN(setDistance, Math.round(event.translationX))
x.value = withTiming(120)
},
})
return (
<GestureHandlerRootView style={{ flex: 1, padding: 24 }}>
<Text>Dragged {distance} points</Text>
<GestureDetector gesture={pan}>
<Animated.View style={[{ width: 72, height: 72, backgroundColor: 'blue' }, style]} />
</GestureDetector>
</GestureHandlerRootView>
)
}

The pan writes a shared value on the UI runtime every frame, animates back on release, and reports the final distance to React state through scheduleOnRN. The root view wraps the gesture area.

How it works

Wrap the gesture area in GestureHandlerRootView. A route-local wrapper covers gestures that stay in the route; content presented in a separate native sheet needs its own wrapper, since the handler tree ends at the presentation boundary. One does not install or wrap Gesture Handler for every app automatically.

Gesture callbacks run on the UI runtime, where shared values live. Reads and writes there never cross to JavaScript, which is why the drag stays at frame rate under JS load. When a callback must update React state, scheduleOnRN hops the call to the React Native runtime with its arguments. Animated styles subscribe to shared values and recompute on the UI side; withTiming drives the value back on a curve without further callbacks.

SwiftUI gestures on iOS views

One.iOS views also take SwiftUI’s own gestures through swiftStyle, with gesture, highPriorityGesture and simultaneousGesture. Each takes a kind (drag, tap, spatialTap, longPress, magnify, rotate), the gesture’s initializer options, and callbacks:

<One.iOS.Text
text="Drag me"
swiftStyle={{
gesture: {
kind: 'drag',
minimumDistance: 4,
onChanged: ({ translation }) => setDragging(translation.height > 0),
onEnded: ({ translation, velocity, predictedEndTranslation }) =>
settle(predictedEndTranslation.height),
},
}}
/>

onEnded receives the gesture’s value: for a drag, its location, translation, velocity and predicted end. onChanged is available on every kind except tap, and native only reports changes when you pass it. Each change crosses to JavaScript, so use it for state and haptics. To move a view with the finger, bind the drag to native state instead.

Binding a drag to native state

A drag can write a useNativeState number itself, so the view follows the finger without waiting for JavaScript. Continuous number fields in swiftStyle (CGFloat or Double in SwiftUI, such as width, height, offset and the frame sizes) accept the same handle and read it natively.

const offset = useNativeState(0)
<One.iOS.Rectangle
style={{ height: 700 }}
swiftStyle={{
offset: { x: 0, y: offset },
gesture: {
kind: 'drag',
bind: { state: offset, axis: 'y', range: [0, 600] },
snap: {
targets: [0, 300, 600],
animation: { kind: 'spring', response: 0.35, dampingFraction: 0.8 },
},
onEnded: ({ snapTarget }) => setDetent(snapTarget),
},
}}
/>

API reference

PackagesTypeDescription
react-native-gesture-handler~3.3.0Detectors, gestures, and the root view.
~3.3.0Detectors, gestures, and the root view.
react-native-reanimated~4.7.1Shared values, animated styles, and drivers.
~4.7.1Shared values, animated styles, and drivers.
react-native-worklets~0.13.0The UI runtime and thread hops.
~0.13.0The UI runtime and thread hops.

Platform differences

iOSAndroidWeb
GesturesNative handlerNative handlerJS fallback
AnimationUI runtimeUI runtimeMain thread
TransformOXC pluginOXC pluginOXC plugin

Limits and gotchas

Examples

Snap a slider back on release

import { GestureDetector, GestureHandlerRootView, usePanGesture } from 'react-native-gesture-handler'
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
function SnapSlider() {
const x = useSharedValue(0)
const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }))
const pan = usePanGesture({
onUpdate: (event) => {
x.value = Math.max(0, Math.min(200, event.translationX))
},
onDeactivate: () => {
x.value = withSpring(0)
},
})
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<GestureDetector gesture={pan}>
<Animated.View style={[{ width: 48, height: 48, borderRadius: 24 }, style]} />
</GestureDetector>
</GestureHandlerRootView>
)
}

Clamping in the update handler bounds the drag without any React involvement, and the spring returns the knob on release. The whole interaction runs on the UI runtime.

Report a swipe to React state

import { useState } from 'react'
import { Text } from 'react-native'
import { GestureDetector, GestureHandlerRootView, usePanGesture } from 'react-native-gesture-handler'
import { scheduleOnRN } from 'react-native-worklets'
function SwipeCounter() {
const [swipes, setSwipes] = useState(0)
const pan = usePanGesture({
onDeactivate: (event) => {
if (Math.abs(event.translationX) > 80) {
scheduleOnRN(setSwipes, swipes + 1)
}
},
})
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<GestureDetector gesture={pan}>
<Text>Swipes: {swipes}</Text>
</GestureDetector>
</GestureHandlerRootView>
)
}

The threshold check runs on the UI side so short drags never wake React. Only a committed swipe hops over to bump the counter.

Edit this page on GitHub.