← All shorts
36Reanimated 3.0+2 min read

Reanimated Worklets: Animation Off the JS Thread

Worklets run small JS functions on the UI thread so gesture-driven animations don't stutter when the JS thread is busy.

PanResponder computes its delta in JS on every touch move. Fine, until the JS thread is busy re-rendering or waiting on a callback. Then the dragged card trails your finger.

#The old way

import { useRef } from 'react';
import { Animated, PanResponder, StyleSheet } from 'react-native';

export function DraggableCard() {
  const pan = useRef(new Animated.ValueXY()).current;

  const panResponder = useRef(
    PanResponder.create({
      onMoveShouldSetPanResponder: () => true,
      onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], {
        useNativeDriver: false,
      }),
      onPanResponderRelease: () => {
        Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false }).start();
      },
    })
  ).current;

  return (
    <Animated.View
      {...panResponder.panHandlers}
      style={[styles.card, { transform: pan.getTranslateTransform() }]}
    />
  );
}

const styles = StyleSheet.create({
  card: { width: 120, height: 160, borderRadius: 16, backgroundColor: '#3478f6' },
});

#The new way

import { StyleSheet } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

export function DraggableCard() {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);

  const pan = Gesture.Pan()
    .onChange((event) => {
      translateX.value += event.changeX;
      translateY.value += event.changeY;
    })
    .onEnd(() => {
      translateX.value = withSpring(0);
      translateY.value = withSpring(0);
    });

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }, { translateY: translateY.value }],
  }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.card, animatedStyle]} />
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  card: { width: 120, height: 160, borderRadius: 16, backgroundColor: '#3478f6' },
});

Three worklets hide in there: onChange, onEnd, and the function handed to useAnimatedStyle. The Babel plugin compiles each one to run on the UI thread's own JS runtime, in step with the gesture.

Nothing crosses the bridge per frame.

#Why it matters

  • Gesture handling and style recalculation both land on the UI thread. Animations stay smooth even when the JS thread is blocked.
  • Physics too. withSpring/withTiming compute on the UI thread, not just the final style write.
  • The API reads like plain React state. But nothing here waits on a JS thread that might be busy.

#Gotcha

Worklets live in a separate JS runtime on the UI thread. They close over shared values, other worklets, and plain serializable data. Nothing else.

Reference a normal JS variable, call a non-worklet function, or call setState directly inside onChange, and you get a throw or a silent no-op. Hop back to the JS thread explicitly with runOnJS().

react-nativereanimatedperformance

Related shorts