Skip to main content
Technology & EngineeringReact Native409 lines

Reanimated Animations

High-performance animations in React Native using Reanimated and Gesture Handler

Quick Summary15 lines
You are an expert in animations for building cross-platform mobile apps with React Native.

## Key Points

- Always mark worklet functions with the `"worklet"` directive or use them inside `useAnimatedStyle` / gesture callbacks where the Reanimated Babel plugin infers it.
- Use `withSpring` for natural-feeling interactions (drags, button presses) and `withTiming` for precise duration-controlled transitions (fade in/out, color changes).
- Use `runOnJS` to call regular JavaScript functions from worklets (e.g., dispatching state updates, navigation). Never call JS functions directly from the UI thread.
- Set `scrollEventThrottle={16}` on ScrollView when using `useAnimatedScrollHandler` for smooth 60fps tracking.
- Prefer layout animations (`entering`, `exiting`, `layout` props) over manual mount/unmount animations — they are declarative and handle interruption correctly.
- Use `useReducedMotion` from Reanimated to respect the user's accessibility settings.
- **Accessing shared values with `.value` in animated styles**: Inside `useAnimatedStyle`, always read `.value`. Outside worklets, `.value` triggers a re-render — this is by design.
- **Gesture conflicts**: When nesting gestures (e.g., swipeable row inside a scrollable list), use `Gesture.Simultaneous`, `Gesture.Exclusive`, or `Gesture.Race` to define precedence.
- **Missing GestureHandlerRootView**: The app root must be wrapped in `<GestureHandlerRootView style={{ flex: 1 }}>`. Expo Router does this automatically.
skilldb get react-native-skills/Reanimated AnimationsFull skill: 409 lines
Paste into your CLAUDE.md or agent config

Reanimated Animations — React Native

You are an expert in animations for building cross-platform mobile apps with React Native.

Core Philosophy

The fundamental rule of React Native animation is: do not animate on the JavaScript thread. The JS thread handles your business logic, state management, and component rendering. If animations run there too, any expensive computation (re-render, API call, JSON parsing) causes animation frames to drop, producing visible jank. Reanimated solves this by compiling animation logic into worklets that execute directly on the native UI thread, completely independent of JavaScript. This architectural split is why Reanimated animations stay smooth at 60fps even when the JS thread is busy.

Gesture-driven animations should feel physically natural. When a user drags a card, the card should follow their finger with zero perceptible delay. When they release it, it should snap into place with a spring animation that mimics real-world physics, not a linear ease that feels robotic. Spring animations (withSpring) handle interruption gracefully because they account for current velocity -- if the user re-grabs a card mid-animation, the spring picks up from the current position and velocity without a jarring reset. Duration-based animations (withTiming) are appropriate for transitions where precise timing matters (fade-ins, color changes) but feel wrong for direct manipulation.

Shared values are the bridge between the UI thread and the React render cycle. A useSharedValue variable lives on the UI thread and can be read and written from worklets without crossing the JS bridge. Reading .value in a useAnimatedStyle worklet is fast -- it happens on the UI thread. Reading .value outside a worklet triggers a synchronous bridge call, which is by design but should not happen in hot paths. Understanding this dual nature is essential for writing performant animation code.

Anti-Patterns

  • Animating properties that cannot run on the UI thread: Only transform, opacity, backgroundColor, width, height, and a few other layout properties can be animated on the UI thread. Attempting to animate fontSize, fontWeight, or borderStyle either fails silently or falls back to the JS thread, negating Reanimated's performance benefits.

  • Using React state to drive frame-by-frame animations: Calling setState 60 times per second to animate a value causes 60 re-renders per second, overwhelming the JS thread. Shared values and useAnimatedStyle bypass React's render cycle entirely, letting the UI thread handle animation frames independently.

  • Nesting gestures without explicit composition: When a swipeable row is inside a scrollable list, both gestures compete for the same touch events. Without Gesture.Simultaneous, Gesture.Exclusive, or Gesture.Race to define precedence, one gesture randomly wins, creating an unpredictable user experience.

  • Missing GestureHandlerRootView at the app root: The Gesture Handler library requires <GestureHandlerRootView style={{ flex: 1 }}> wrapping the entire app. Without it, gestures silently fail on Android. Expo Router sets this up automatically, but bare React Native projects must add it manually.

  • Not handling interrupted animations: When a user triggers a new animation before the previous one finishes, shared values must update correctly from the current position, not from the original start position. withSpring handles this naturally by reading current velocity, but custom withTiming sequences may need explicit cancellation logic.

Overview

React Native Reanimated runs animations on the UI thread using worklets — small JavaScript functions compiled to run outside the JS thread. Combined with React Native Gesture Handler, it enables fluid 60fps+ gesture-driven animations. Reanimated 3 introduced a simplified API with useAnimatedStyle, useSharedValue, and layout animations. This skill covers Reanimated 3, Gesture Handler 2, and common animation patterns for mobile UIs.

Core Concepts

Shared Values and Animated Styles

import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
} from "react-native-reanimated";

function ScaleButton() {
  const scale = useSharedValue(1);

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

  const handlePressIn = () => {
    scale.value = withSpring(0.95, { damping: 15, stiffness: 150 });
  };

  const handlePressOut = () => {
    scale.value = withSpring(1, { damping: 15, stiffness: 150 });
  };

  return (
    <Animated.View style={animatedStyle}>
      <Pressable onPressIn={handlePressIn} onPressOut={handlePressOut}>
        <Text style={styles.buttonText}>Press Me</Text>
      </Pressable>
    </Animated.View>
  );
}

Animation Types

import {
  withSpring,
  withTiming,
  withDecay,
  withRepeat,
  withSequence,
  withDelay,
  Easing,
} from "react-native-reanimated";

// Spring (physics-based)
offset.value = withSpring(100, {
  damping: 12,
  stiffness: 100,
  mass: 0.5,
});

// Timing (duration-based)
opacity.value = withTiming(1, {
  duration: 300,
  easing: Easing.bezier(0.25, 0.1, 0.25, 1),
});

// Decay (momentum-based, for fling gestures)
translationX.value = withDecay({
  velocity: velocityX,
  clamp: [-200, 200],
});

// Sequence
scale.value = withSequence(
  withTiming(1.2, { duration: 150 }),
  withTiming(0.9, { duration: 100 }),
  withSpring(1)
);

// Repeat
rotation.value = withRepeat(
  withTiming(360, { duration: 1000, easing: Easing.linear }),
  -1, // Infinite
  false // Don't reverse
);

// Delay
opacity.value = withDelay(500, withTiming(1, { duration: 300 }));

Gesture Handler Integration

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

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

  const panGesture = Gesture.Pan()
    .onStart(() => {
      savedX.value = translateX.value;
      savedY.value = translateY.value;
    })
    .onUpdate((event) => {
      translateX.value = savedX.value + event.translationX;
      translateY.value = savedY.value + event.translationY;
    })
    .onEnd(() => {
      translateX.value = withSpring(0);
      translateY.value = withSpring(0);
    });

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

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

Implementation Patterns

Swipe-to-Delete

import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  runOnJS,
  interpolate,
  Extrapolation,
} from "react-native-reanimated";

const SWIPE_THRESHOLD = -100;

function SwipeableRow({ onDelete, children }: SwipeableRowProps) {
  const translateX = useSharedValue(0);

  const panGesture = Gesture.Pan()
    .activeOffsetX([-10, 10])
    .onUpdate((event) => {
      translateX.value = Math.min(0, event.translationX);
    })
    .onEnd((event) => {
      if (translateX.value < SWIPE_THRESHOLD) {
        translateX.value = withTiming(-200, { duration: 200 });
        runOnJS(onDelete)();
      } else {
        translateX.value = withSpring(0);
      }
    });

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

  const deleteStyle = useAnimatedStyle(() => ({
    opacity: interpolate(
      translateX.value,
      [-100, 0],
      [1, 0],
      Extrapolation.CLAMP
    ),
  }));

  return (
    <View>
      <Animated.View style={[styles.deleteBackground, deleteStyle]}>
        <Text style={styles.deleteText}>Delete</Text>
      </Animated.View>
      <GestureDetector gesture={panGesture}>
        <Animated.View style={rowStyle}>{children}</Animated.View>
      </GestureDetector>
    </View>
  );
}

Layout Animations

import Animated, {
  FadeIn,
  FadeOut,
  SlideInRight,
  SlideOutLeft,
  LinearTransition,
  BounceIn,
} from "react-native-reanimated";

function AnimatedList({ items }: { items: Item[] }) {
  return (
    <Animated.FlatList
      data={items}
      itemLayoutAnimation={LinearTransition.springify()}
      renderItem={({ item, index }) => (
        <Animated.View
          entering={FadeIn.delay(index * 50).springify()}
          exiting={SlideOutLeft.duration(200)}
          layout={LinearTransition.springify()}
          style={styles.listItem}
        >
          <Text>{item.name}</Text>
        </Animated.View>
      )}
    />
  );
}

// Conditional rendering with layout animations
function ExpandableCard() {
  const [expanded, setExpanded] = useState(false);

  return (
    <Pressable onPress={() => setExpanded(!expanded)}>
      <Animated.View layout={LinearTransition.springify()} style={styles.card}>
        <Text>Header</Text>
        {expanded && (
          <Animated.View entering={FadeIn} exiting={FadeOut}>
            <Text>Expanded content goes here</Text>
          </Animated.View>
        )}
      </Animated.View>
    </Pressable>
  );
}

Shared Element Transitions

import Animated, { SharedTransition } from "react-native-reanimated";

const customTransition = SharedTransition.custom((values) => {
  "worklet";
  return {
    originX: withSpring(values.targetOriginX),
    originY: withSpring(values.targetOriginY),
    width: withSpring(values.targetWidth),
    height: withSpring(values.targetHeight),
  };
});

// List screen
function ListScreen() {
  return items.map((item) => (
    <Pressable onPress={() => navigate("Detail", { id: item.id })}>
      <Animated.Image
        sharedTransitionTag={`image-${item.id}`}
        sharedTransitionStyle={customTransition}
        source={{ uri: item.imageUrl }}
        style={styles.thumbnail}
      />
    </Pressable>
  ));
}

// Detail screen
function DetailScreen({ route }) {
  const { id } = route.params;
  return (
    <Animated.Image
      sharedTransitionTag={`image-${id}`}
      sharedTransitionStyle={customTransition}
      source={{ uri: getItem(id).imageUrl }}
      style={styles.heroImage}
    />
  );
}

Interpolation and Scroll Animations

import Animated, {
  useAnimatedScrollHandler,
  useSharedValue,
  useAnimatedStyle,
  interpolate,
  Extrapolation,
} from "react-native-reanimated";

function ParallaxHeader() {
  const scrollY = useSharedValue(0);

  const scrollHandler = useAnimatedScrollHandler({
    onScroll: (event) => {
      scrollY.value = event.contentOffset.y;
    },
  });

  const headerStyle = useAnimatedStyle(() => ({
    height: interpolate(
      scrollY.value,
      [-100, 0, 200],
      [400, 300, 100],
      Extrapolation.CLAMP
    ),
    opacity: interpolate(
      scrollY.value,
      [0, 200],
      [1, 0],
      Extrapolation.CLAMP
    ),
  }));

  const titleStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateY: interpolate(
          scrollY.value,
          [0, 200],
          [0, -50],
          Extrapolation.CLAMP
        ),
      },
      {
        scale: interpolate(
          scrollY.value,
          [0, 200],
          [1, 0.7],
          Extrapolation.CLAMP
        ),
      },
    ],
  }));

  return (
    <View style={{ flex: 1 }}>
      <Animated.View style={[styles.header, headerStyle]}>
        <Animated.Text style={[styles.title, titleStyle]}>
          Profile
        </Animated.Text>
      </Animated.View>
      <Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16}>
        <View style={{ paddingTop: 300 }}>{/* Content */}</View>
      </Animated.ScrollView>
    </View>
  );
}

Best Practices

  • Always mark worklet functions with the "worklet" directive or use them inside useAnimatedStyle / gesture callbacks where the Reanimated Babel plugin infers it.
  • Use withSpring for natural-feeling interactions (drags, button presses) and withTiming for precise duration-controlled transitions (fade in/out, color changes).
  • Use runOnJS to call regular JavaScript functions from worklets (e.g., dispatching state updates, navigation). Never call JS functions directly from the UI thread.
  • Set scrollEventThrottle={16} on ScrollView when using useAnimatedScrollHandler for smooth 60fps tracking.
  • Prefer layout animations (entering, exiting, layout props) over manual mount/unmount animations — they are declarative and handle interruption correctly.
  • Use useReducedMotion from Reanimated to respect the user's accessibility settings.

Common Pitfalls

  • Accessing shared values with .value in animated styles: Inside useAnimatedStyle, always read .value. Outside worklets, .value triggers a re-render — this is by design.
  • Gesture conflicts: When nesting gestures (e.g., swipeable row inside a scrollable list), use Gesture.Simultaneous, Gesture.Exclusive, or Gesture.Race to define precedence.
  • Missing GestureHandlerRootView: The app root must be wrapped in <GestureHandlerRootView style={{ flex: 1 }}>. Expo Router does this automatically.
  • Animating non-animatable properties: Only transform, opacity, backgroundColor, width, height, and a few other properties can be animated on the UI thread. Animating borderRadius works, but fontSize does not.
  • Not handling interrupted animations: When a user triggers a new animation before the previous one finishes, ensure shared values update correctly. withSpring handles this well by reading the current velocity.

Install this skill directly: skilldb add react-native-skills

Get CLI access →