Steven's Knowledge

Advanced Animations

React Native animations — Reanimated v3, Gesture Handler, layout animations, Skia, shared transitions

Advanced Animations

Smooth animation on mobile is a 16.67 ms budget per frame. Miss it and users feel it instantly. Most animation approaches in React Native fail because they run on the wrong thread. This guide covers the architecture that makes 60 fps possible and the tools that implement it.

Animation Architecture

JS Thread vs UI Thread

React Native has two execution contexts that matter for animation:

┌─────────────────┐    bridge / JSI    ┌──────────────────┐
│    JS Thread    │ ←────────────────→ │    UI Thread     │
│  (React logic)  │                    │  (native views)  │
└─────────────────┘                    └──────────────────┘
   setState()                            Layout + Paint
   fetch()                               Touch delivery
   Business logic                        60 fps target

When an animation value changes on the JS thread and must cross the bridge to update a native view property, the round trip costs serialization time. If the JS thread is also processing a network response or running a re-render, the animation frame is dropped. This is the fundamental problem with most animation approaches.

The Animated API Limitations

The built-in Animated API ships with React Native and offers useNativeDriver to offload animations to the UI thread. But the restriction is severe: useNativeDriver only works with transform and opacity. Any other property (width, height, backgroundColor, borderRadius) falls back to JS-driven frames.

// This works on the UI thread
Animated.timing(opacity, {
  toValue: 0,
  duration: 300,
  useNativeDriver: true, // OK: opacity is supported
}).start();

// This crashes with useNativeDriver: true
Animated.timing(height, {
  toValue: 0,
  duration: 300,
  useNativeDriver: true, // ERROR: height is not supported
}).start();

The Animated API also cannot respond to gestures frame-by-frame without crossing the bridge. Animated.event with useNativeDriver helps for scroll-linked animations, but complex gesture-driven interactions still stutter.

Reanimated v3 Worklet Model

Reanimated solves this by running JavaScript functions directly on the UI thread. These functions are called worklets -- normal JavaScript functions marked with the 'worklet' directive that get compiled and shipped to the UI thread runtime via a Babel plugin.

import { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';

const offset = useSharedValue(0);

// This function runs on the UI thread, not the JS thread
const animatedStyle = useAnimatedStyle(() => {
  'worklet';
  return {
    transform: [{ translateX: offset.value }],
    backgroundColor: offset.value > 100 ? 'red' : 'blue', // any property works
  };
});

No bridge crossing. No serialization. The style update happens in the same frame as the gesture or timer tick.

Comparison Table

FeatureAnimated APIReanimated v2Reanimated v3
UI thread executionOnly transform/opacityYes (worklets)Yes (worklets)
Gesture integrationLimited (Animated.event)react-native-gesture-handlerSame, improved API
Animatable propertiestransform, opacity (native)All style propertiesAll style properties
Layout animationsNoYesYes, improved
Shared element transitionsNoNoYes (experimental)
API styleImperativeHook-basedHook-based
Bundle size impact0 (built-in)~150 KB~150 KB
Babel plugin requiredNoYesYes
Web supportPartialPartialYes
New Architecture (Fabric)PartialYesYes

Reanimated v3 is not a rewrite of v2. It is the same library with additional APIs (shared transitions, reduced API for worklet marking). The v2 API is fully supported in v3. The 'worklet' directive is now automatically inferred for useAnimatedStyle callbacks.

Reanimated Core Concepts

Shared Values

useSharedValue creates a reactive value that lives on both threads. Reading .value on the UI thread is synchronous. Reading it on the JS thread reflects the last known value (no bridge call needed because it is backed by a C++ data structure via JSI).

import { useSharedValue } from 'react-native-reanimated';

function MyComponent() {
  const translateX = useSharedValue(0);
  const opacity = useSharedValue(1);
  const scale = useSharedValue(1);

  // Updating from JS thread — the value propagates to UI thread automatically
  const handlePress = () => {
    translateX.value = 100;
  };

  return <Animated.View style={useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }, { scale: scale.value }],
    opacity: opacity.value,
  }))} />;
}

useAnimatedStyle

Derives a style object from shared values. The callback runs on the UI thread whenever any referenced shared value changes.

const animatedStyle = useAnimatedStyle(() => ({
  transform: [
    { translateX: offset.value },
    { scale: interpolate(offset.value, [0, 100], [1, 1.5], Extrapolation.CLAMP) },
  ],
  opacity: interpolate(offset.value, [0, 50, 100], [1, 0.5, 0]),
  borderRadius: offset.value / 10,
}));

useDerivedValue

Computes a value from other shared values. Runs on the UI thread. Useful for intermediate calculations that multiple animated styles depend on.

import { useDerivedValue } from 'react-native-reanimated';

const progress = useSharedValue(0);

const clampedProgress = useDerivedValue(() => {
  return Math.min(Math.max(progress.value, 0), 1);
});

const backgroundColor = useDerivedValue(() => {
  return interpolateColor(
    clampedProgress.value,
    [0, 0.5, 1],
    ['#ff0000', '#ffff00', '#00ff00'],
  );
});

Animation Drivers

Reanimated provides three fundamental animation drivers:

import {
  withTiming,
  withSpring,
  withDecay,
  Easing,
} from 'react-native-reanimated';

// Timing: fixed duration, configurable easing
offset.value = withTiming(100, {
  duration: 300,
  easing: Easing.bezier(0.25, 0.1, 0.25, 1),
});

// Spring: physics-based, no fixed duration
offset.value = withSpring(100, {
  damping: 15,        // lower = more bouncy
  stiffness: 150,     // higher = snappier
  mass: 1,            // higher = slower
  overshootClamping: false,
  restDisplacementThreshold: 0.01,
  restSpeedThreshold: 2,
});

// Decay: velocity-based, slows to stop (fling gestures)
offset.value = withDecay({
  velocity: velocityFromGesture,
  deceleration: 0.998,  // 0.998 is the default
  clamp: [0, MAX_SCROLL],
});

Animation Composition

Chain, delay, and repeat animations with composition functions:

import {
  withSequence,
  withDelay,
  withRepeat,
} from 'react-native-reanimated';

// Shake animation: sequence of left-right movements
offset.value = withSequence(
  withTiming(-10, { duration: 50 }),
  withRepeat(withTiming(10, { duration: 100 }), 3, true),
  withTiming(0, { duration: 50 }),
);

// Staggered entrance: delay each item
items.forEach((_, index) => {
  opacity[index].value = withDelay(
    index * 100,
    withTiming(1, { duration: 400 }),
  );
});

// Infinite pulse
scale.value = withRepeat(
  withSequence(
    withTiming(1.2, { duration: 600 }),
    withTiming(1, { duration: 600 }),
  ),
  -1, // -1 = infinite
  true,
);

Easing Functions

import { Easing } from 'react-native-reanimated';

// Built-in curves
Easing.linear;
Easing.ease;          // equivalent to CSS ease
Easing.in(Easing.quad);
Easing.out(Easing.cubic);
Easing.inOut(Easing.exp);

// Custom cubic bezier (matches CSS transition-timing-function)
Easing.bezier(0.42, 0, 0.58, 1);

// Step function (frame-by-frame sprite animation)
Easing.steps(5, true);

Spring Animation with Shared Values (Complete Example)

import React from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  interpolate,
  interpolateColor,
} from 'react-native-reanimated';

const SPRING_CONFIG = {
  damping: 12,
  stiffness: 180,
  mass: 0.8,
};

export function AnimatedCard() {
  const pressed = useSharedValue(0); // 0 = idle, 1 = pressed

  const cardStyle = useAnimatedStyle(() => {
    const scale = interpolate(pressed.value, [0, 1], [1, 0.95]);
    const shadowRadius = interpolate(pressed.value, [0, 1], [8, 2]);
    const bgColor = interpolateColor(
      pressed.value,
      [0, 1],
      ['#ffffff', '#f0f0f0'],
    );
    return {
      transform: [{ scale }],
      shadowRadius,
      backgroundColor: bgColor,
    };
  });

  return (
    <Pressable
      onPressIn={() => { pressed.value = withSpring(1, SPRING_CONFIG); }}
      onPressOut={() => { pressed.value = withSpring(0, SPRING_CONFIG); }}
    >
      <Animated.View style={[styles.card, cardStyle]}>
        <Text style={styles.text}>Press Me</Text>
      </Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  card: {
    padding: 24,
    borderRadius: 16,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.15,
    elevation: 6,
  },
  text: { fontSize: 18, fontWeight: '600', textAlign: 'center' },
});

Gesture Handler Integration

The Problem

React Native's built-in PanResponder runs gesture callbacks on the JS thread. Every onPanResponderMove crosses the bridge. If the JS thread is busy, gestures stutter.

react-native-gesture-handler runs gesture recognition on the native thread. Combined with Reanimated, gesture events flow directly to worklets on the UI thread -- zero bridge crossings per frame.

Gesture Composition

import { Gesture, GestureDetector } from 'react-native-gesture-handler';

// Individual gestures
const tap = Gesture.Tap().onEnd(() => { /* ... */ });
const pan = Gesture.Pan().onUpdate((e) => { /* ... */ });
const pinch = Gesture.Pinch().onUpdate((e) => { /* ... */ });
const rotation = Gesture.Rotation().onUpdate((e) => { /* ... */ });
const longPress = Gesture.LongPress().minDuration(500).onEnd(() => { /* ... */ });
const fling = Gesture.Fling().direction(Directions.RIGHT).onEnd(() => { /* ... */ });

// Simultaneous: both gestures can be active at the same time
const pinchAndPan = Gesture.Simultaneous(pinch, pan);

// Exclusive: only the first matching gesture wins
const tapOrLongPress = Gesture.Exclusive(longPress, tap);

// Sequential: second gesture only activates after first completes
const doubleTap = Gesture.Tap().numberOfTaps(2);
const singleTap = Gesture.Tap();
const tapGesture = Gesture.Exclusive(doubleTap, singleTap);

GestureDetector

GestureDetector is the modern API (replaces TapGestureHandler, PanGestureHandler, etc.). It wraps a single animated view and accepts a composed gesture.

<GestureDetector gesture={pinchAndPan}>
  <Animated.View style={[styles.box, animatedStyle]} />
</GestureDetector>

GestureDetector must wrap an Animated.View from Reanimated (not from react-native). Wrapping a plain View will not receive animated style updates.

Pan Gesture Controlling a Shared Value

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

export function DraggableBox() {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const context = useSharedValue({ x: 0, y: 0 });

  const panGesture = Gesture.Pan()
    .onStart(() => {
      context.value = { x: translateX.value, y: translateY.value };
    })
    .onUpdate((event) => {
      translateX.value = context.value.x + event.translationX;
      translateY.value = context.value.y + event.translationY;
    })
    .onEnd((event) => {
      translateX.value = withDecay({ velocity: event.velocityX });
      translateY.value = withDecay({ velocity: event.velocityY });
    });

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

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

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  box: { width: 100, height: 100, backgroundColor: '#3b82f6', borderRadius: 12 },
});

Common Animation Patterns

Drag and Drop with Snap-Back

A draggable card that snaps back to its origin when released, using spring physics.

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

const SNAP_POINTS = [0, 120, 240];

function snapTo(value: number, points: number[]): number {
  'worklet';
  return points.reduce((prev, curr) =>
    Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
  );
}

export function DraggableCard() {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const startX = useSharedValue(0);
  const startY = useSharedValue(0);
  const scale = useSharedValue(1);

  const gesture = Gesture.Pan()
    .onStart(() => {
      startX.value = translateX.value;
      startY.value = translateY.value;
      scale.value = withSpring(1.05);
    })
    .onUpdate((e) => {
      translateX.value = startX.value + e.translationX;
      translateY.value = startY.value + e.translationY;
    })
    .onEnd(() => {
      // Snap to nearest grid point on X axis, snap back to 0 on Y
      translateX.value = withSpring(snapTo(translateX.value, SNAP_POINTS), {
        damping: 15,
        stiffness: 150,
      });
      translateY.value = withSpring(0, { damping: 15, stiffness: 150 });
      scale.value = withSpring(1);
    });

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

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

const styles = StyleSheet.create({
  snapContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  card: {
    width: 200,
    height: 120,
    backgroundColor: '#6366f1',
    borderRadius: 16,
    justifyContent: 'center',
    alignItems: 'center',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.2,
    shadowRadius: 8,
    elevation: 8,
  },
  cardText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

Swipe to Delete

A list item that reveals a delete action on swipe, collapses its height when deleted, and calls back to the parent.

import React, { useCallback } from 'react';
import { StyleSheet, Text, View, Dimensions } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  withSpring,
  interpolate,
  interpolateColor,
  runOnJS,
  Extrapolation,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

const SCREEN_WIDTH = Dimensions.get('window').width;
const SWIPE_THRESHOLD = -SCREEN_WIDTH * 0.3;

interface SwipeableRowProps {
  label: string;
  onDelete: () => void;
}

export function SwipeableRow({ label, onDelete }: SwipeableRowProps) {
  const translateX = useSharedValue(0);
  const rowHeight = useSharedValue(72);
  const opacity = useSharedValue(1);

  const handleDelete = useCallback(() => {
    onDelete();
  }, [onDelete]);

  const panGesture = Gesture.Pan()
    .activeOffsetX([-10, 10])
    .onUpdate((e) => {
      translateX.value = Math.min(0, e.translationX); // only allow left swipe
    })
    .onEnd((e) => {
      if (translateX.value < SWIPE_THRESHOLD) {
        // Swipe past threshold — animate out and collapse
        translateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 });
        rowHeight.value = withTiming(0, { duration: 300 });
        opacity.value = withTiming(0, { duration: 300 }, () => {
          runOnJS(handleDelete)();
        });
      } else {
        // Snap back
        translateX.value = withSpring(0);
      }
    });

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

  const containerStyle = useAnimatedStyle(() => ({
    height: rowHeight.value,
    opacity: opacity.value,
    overflow: 'hidden' as const,
  }));

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

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

const styles = StyleSheet.create({
  deleteBackground: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: '#ef4444',
    justifyContent: 'center',
    alignItems: 'flex-end',
    paddingRight: 24,
  },
  deleteText: { color: '#fff', fontWeight: '700', fontSize: 16 },
  row: {
    height: 72,
    backgroundColor: '#fff',
    justifyContent: 'center',
    paddingHorizontal: 20,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: '#e5e7eb',
  },
  rowText: { fontSize: 16 },
});

Pinch to Zoom (Image Viewer)

Combines pinch and pan gestures simultaneously for a production-quality image viewer with bounds clamping.

import React from 'react';
import { StyleSheet, View, Dimensions, Image } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  clamp,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const MIN_SCALE = 1;
const MAX_SCALE = 5;

export function ImageViewer({ uri }: { uri: string }) {
  const scale = useSharedValue(1);
  const savedScale = useSharedValue(1);
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const savedTranslateX = useSharedValue(0);
  const savedTranslateY = useSharedValue(0);

  const pinchGesture = Gesture.Pinch()
    .onStart(() => {
      savedScale.value = scale.value;
    })
    .onUpdate((e) => {
      scale.value = clamp(savedScale.value * e.scale, MIN_SCALE, MAX_SCALE);
    })
    .onEnd(() => {
      if (scale.value < 1) {
        scale.value = withTiming(1);
        translateX.value = withTiming(0);
        translateY.value = withTiming(0);
      }
    });

  const panGesture = Gesture.Pan()
    .minPointers(2)
    .onStart(() => {
      savedTranslateX.value = translateX.value;
      savedTranslateY.value = translateY.value;
    })
    .onUpdate((e) => {
      const maxX = (SCREEN_WIDTH * (scale.value - 1)) / 2;
      const maxY = (SCREEN_HEIGHT * (scale.value - 1)) / 2;
      translateX.value = clamp(
        savedTranslateX.value + e.translationX,
        -maxX,
        maxX,
      );
      translateY.value = clamp(
        savedTranslateY.value + e.translationY,
        -maxY,
        maxY,
      );
    });

  const doubleTap = Gesture.Tap()
    .numberOfTaps(2)
    .onEnd((e) => {
      if (scale.value > 1) {
        scale.value = withTiming(1);
        translateX.value = withTiming(0);
        translateY.value = withTiming(0);
      } else {
        scale.value = withTiming(2.5);
        translateX.value = withTiming(
          (SCREEN_WIDTH / 2 - e.x) * 1.5,
        );
        translateY.value = withTiming(
          (SCREEN_HEIGHT / 2 - e.y) * 1.5,
        );
      }
    });

  const composedGesture = Gesture.Simultaneous(
    pinchGesture,
    panGesture,
    doubleTap,
  );

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

  return (
    <View style={styles.viewerContainer}>
      <GestureDetector gesture={composedGesture}>
        <Animated.Image
          source={{ uri }}
          style={[styles.image, animatedStyle]}
          resizeMode="contain"
        />
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  viewerContainer: {
    flex: 1,
    backgroundColor: '#000',
    justifyContent: 'center',
    alignItems: 'center',
  },
  image: { width: SCREEN_WIDTH, height: SCREEN_HEIGHT },
});

Bottom Sheet with Multiple Snap Points

A custom bottom sheet driven by pan gestures and velocity-based snap logic.

import React, { useCallback } from 'react';
import { StyleSheet, View, Text, Dimensions } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  interpolate,
  useAnimatedScrollHandler,
  clamp,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const SNAP_POINTS = [SCREEN_HEIGHT * 0.08, SCREEN_HEIGHT * 0.5, SCREEN_HEIGHT * 0.92];
// SNAP_POINTS: collapsed (8%), half (50%), expanded (92%)

function snapToNearest(y: number, velocity: number, points: number[]): number {
  'worklet';
  // If velocity is high enough, snap in the direction of the swipe
  if (Math.abs(velocity) > 500) {
    const direction = velocity > 0 ? 1 : -1; // positive velocity = downward
    const currentIndex = points.reduce((closest, point, index) =>
      Math.abs(point - y) < Math.abs(points[closest] - y) ? index : closest, 0);
    const nextIndex = clamp(currentIndex + direction, 0, points.length - 1);
    return points[nextIndex];
  }
  // Otherwise snap to nearest
  return points.reduce((prev, curr) =>
    Math.abs(curr - y) < Math.abs(prev - y) ? curr : prev
  );
}

export function BottomSheet({ children }: { children: React.ReactNode }) {
  // translateY represents how far down from the top. Higher value = more collapsed.
  const translateY = useSharedValue(SCREEN_HEIGHT - SNAP_POINTS[0]);
  const context = useSharedValue(0);

  const panGesture = Gesture.Pan()
    .onStart(() => {
      context.value = translateY.value;
    })
    .onUpdate((e) => {
      translateY.value = clamp(
        context.value + e.translationY,
        SCREEN_HEIGHT - SNAP_POINTS[2],
        SCREEN_HEIGHT - SNAP_POINTS[0],
      );
    })
    .onEnd((e) => {
      const currentHeight = SCREEN_HEIGHT - translateY.value;
      const targetHeight = snapToNearest(currentHeight, -e.velocityY, SNAP_POINTS);
      translateY.value = withSpring(SCREEN_HEIGHT - targetHeight, {
        damping: 20,
        stiffness: 200,
        mass: 0.5,
      });
    });

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

  const backdropStyle = useAnimatedStyle(() => ({
    opacity: interpolate(
      translateY.value,
      [SCREEN_HEIGHT - SNAP_POINTS[2], SCREEN_HEIGHT - SNAP_POINTS[0]],
      [0.5, 0],
    ),
  }));

  return (
    <>
      <Animated.View
        style={[styles.backdrop, backdropStyle]}
        pointerEvents="none"
      />
      <GestureDetector gesture={panGesture}>
        <Animated.View style={[styles.sheet, sheetStyle]}>
          <View style={styles.handle} />
          {children}
        </Animated.View>
      </GestureDetector>
    </>
  );
}

const styles = StyleSheet.create({
  backdrop: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: '#000',
  },
  sheet: {
    position: 'absolute',
    left: 0,
    right: 0,
    height: SCREEN_HEIGHT,
    backgroundColor: '#fff',
    borderTopLeftRadius: 24,
    borderTopRightRadius: 24,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: -4 },
    shadowOpacity: 0.1,
    shadowRadius: 12,
    elevation: 16,
  },
  handle: {
    width: 40,
    height: 4,
    backgroundColor: '#d1d5db',
    borderRadius: 2,
    alignSelf: 'center',
    marginTop: 12,
    marginBottom: 8,
  },
});

Shared Element Transitions

Reanimated v3 provides SharedTransitionTag for animating elements between screens. Assign the same tag to the source and destination components, and Reanimated interpolates position, size, and style between them.

import Animated from 'react-native-reanimated';
import { SharedTransition } from 'react-native-reanimated';

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

// Source screen (list item)
function ListScreen({ navigation }) {
  return (
    <Pressable onPress={() => navigation.navigate('Detail', { id: item.id })}>
      <Animated.Image
        source={{ uri: item.image }}
        style={styles.thumbnail}
        sharedTransitionTag={`image-${item.id}`}
        sharedTransitionStyle={customTransition}
      />
      <Animated.Text
        style={styles.title}
        sharedTransitionTag={`title-${item.id}`}
      >
        {item.title}
      </Animated.Text>
    </Pressable>
  );
}

// Destination screen (detail)
function DetailScreen({ route }) {
  const { id } = route.params;
  return (
    <View>
      <Animated.Image
        source={{ uri: item.image }}
        style={styles.hero}
        sharedTransitionTag={`image-${id}`}
        sharedTransitionStyle={customTransition}
      />
      <Animated.Text
        style={styles.detailTitle}
        sharedTransitionTag={`title-${id}`}
      >
        {item.title}
      </Animated.Text>
    </View>
  );
}

Shared transitions require react-native-screens with native stack navigator. They do not work with JS-based stack navigators. As of Reanimated v3.6, the API is stable on iOS and Android but should be tested thoroughly in your specific navigation structure.

Layout Animations

Layout animations automatically animate components when they enter, exit, or change layout position. They require no manual shared values.

Entering and Exiting

import Animated, {
  FadeIn,
  FadeOut,
  SlideInLeft,
  SlideOutRight,
  BounceIn,
  ZoomIn,
  FlipInXUp,
  LightSpeedInLeft,
} from 'react-native-reanimated';

// Fade in over 500ms with a 200ms delay
<Animated.View entering={FadeIn.duration(500).delay(200)}>
  <Text>Hello</Text>
</Animated.View>

// Slide in from left with spring physics
<Animated.View entering={SlideInLeft.springify().damping(15)}>
  <Text>Slide</Text>
</Animated.View>

// Exit to the right
<Animated.View exiting={SlideOutRight.duration(300)}>
  <Text>Goodbye</Text>
</Animated.View>

Layout Transitions

When a component's position or size changes (sibling added/removed, flex changes), the layout prop animates the change smoothly.

import { LinearTransition } from 'react-native-reanimated';

<Animated.View layout={LinearTransition.springify()}>
  <Text>{item.title}</Text>
</Animated.View>

Custom Entering/Exiting Animations

import { EntryAnimationsValues, ExitAnimationsValues } from 'react-native-reanimated';

const CustomEntering = (values: EntryAnimationsValues) => {
  'worklet';
  const animations = {
    originX: withSpring(values.targetOriginX),
    originY: withSpring(values.targetOriginY),
    opacity: withTiming(1, { duration: 300 }),
    transform: [
      { rotate: withSpring('0deg') },
      { scale: withSpring(1) },
    ],
  };
  const initialValues = {
    originX: values.targetOriginX - 100,
    originY: values.targetOriginY,
    opacity: 0,
    transform: [
      { rotate: '-45deg' },
      { scale: 0.5 },
    ],
  };
  return { initialValues, animations };
};

<Animated.View entering={CustomEntering}>
  <Text>Custom entrance</Text>
</Animated.View>

Animated List with Entering/Exiting Items

import React, { useState, useCallback } from 'react';
import { StyleSheet, View, Text, Pressable, FlatList } from 'react-native';
import Animated, {
  FadeInRight,
  FadeOutLeft,
  LinearTransition,
} from 'react-native-reanimated';

interface Item {
  id: string;
  label: string;
}

let counter = 0;

export function AnimatedList() {
  const [items, setItems] = useState<Item[]>([]);

  const addItem = useCallback(() => {
    const id = String(++counter);
    setItems((prev) => [{ id, label: `Item ${id}` }, ...prev]);
  }, []);

  const removeItem = useCallback((id: string) => {
    setItems((prev) => prev.filter((item) => item.id !== id));
  }, []);

  const renderItem = useCallback(({ item }: { item: Item }) => (
    <Animated.View
      entering={FadeInRight.duration(300)}
      exiting={FadeOutLeft.duration(300)}
      layout={LinearTransition.springify()}
      style={styles.listItem}
    >
      <Text style={styles.listItemText}>{item.label}</Text>
      <Pressable onPress={() => removeItem(item.id)}>
        <Text style={styles.removeBtn}>Remove</Text>
      </Pressable>
    </Animated.View>
  ), [removeItem]);

  return (
    <View style={styles.listContainer}>
      <Pressable style={styles.addBtn} onPress={addItem}>
        <Text style={styles.addBtnText}>Add Item</Text>
      </Pressable>
      <FlatList
        data={items}
        keyExtractor={(item) => item.id}
        renderItem={renderItem}
        itemLayoutAnimation={LinearTransition}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  listContainer: { flex: 1, padding: 16 },
  addBtn: {
    backgroundColor: '#3b82f6',
    padding: 12,
    borderRadius: 8,
    marginBottom: 16,
  },
  addBtnText: { color: '#fff', textAlign: 'center', fontWeight: '600' },
  listItem: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    padding: 16,
    backgroundColor: '#f9fafb',
    borderRadius: 8,
    marginBottom: 8,
  },
  listItemText: { fontSize: 16 },
  removeBtn: { color: '#ef4444', fontWeight: '600' },
});

React Native Skia

When to Use Skia

@shopify/react-native-skia provides a GPU-accelerated 2D canvas. Reanimated animates React Native views (translate, scale, opacity). Skia draws arbitrary shapes, paths, gradients, shaders, and image filters.

Use CaseTool
Moving/scaling existing componentsReanimated
Custom chart renderingSkia
Particle effectsSkia
Blur / image filtersSkia
Path morphingSkia
SVG-like drawingSkia
Component list animationsReanimated Layout Animations
Gesture-driven transformsReanimated + Gesture Handler

Core Primitives

import {
  Canvas,
  Circle,
  Path,
  LinearGradient,
  Blur,
  vec,
  Skia,
} from '@shopify/react-native-skia';

function SkiaBasics() {
  return (
    <Canvas style={{ width: 300, height: 300 }}>
      {/* Circle with gradient fill */}
      <Circle cx={150} cy={150} r={100}>
        <LinearGradient
          start={vec(50, 50)}
          end={vec(250, 250)}
          colors={['#6366f1', '#ec4899']}
        />
      </Circle>

      {/* Path with blur */}
      <Path
        path="M 50 150 Q 150 50 250 150"
        style="stroke"
        strokeWidth={4}
        color="#10b981"
      >
        <Blur blur={2} />
      </Path>
    </Canvas>
  );
}

Integrating Skia with Reanimated

Skia values can be driven by Reanimated shared values using useDerivedValue:

import { Canvas, Circle, vec } from '@shopify/react-native-skia';
import {
  useSharedValue,
  useDerivedValue,
  withRepeat,
  withTiming,
} from 'react-native-reanimated';
import { useEffect } from 'react';

export function PulsingCircle() {
  const progress = useSharedValue(0);

  useEffect(() => {
    progress.value = withRepeat(
      withTiming(1, { duration: 2000 }),
      -1,
      true,
    );
  }, []);

  const radius = useDerivedValue(() => 40 + progress.value * 60);
  const opacity = useDerivedValue(() => 1 - progress.value * 0.6);

  return (
    <Canvas style={{ width: 200, height: 200 }}>
      <Circle cx={100} cy={100} r={radius} color="#6366f1" opacity={opacity} />
    </Canvas>
  );
}

Custom Animated Graph with Skia

A line graph that animates in from left to right with smooth path interpolation.

import React, { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import {
  Canvas,
  Path as SkiaPath,
  Skia,
  LinearGradient,
  vec,
  Group,
  Circle,
} from '@shopify/react-native-skia';
import {
  useSharedValue,
  useDerivedValue,
  withTiming,
  Easing,
} from 'react-native-reanimated';

const GRAPH_WIDTH = 320;
const GRAPH_HEIGHT = 200;
const PADDING = 20;

const DATA = [20, 45, 28, 80, 55, 90, 60, 75, 40, 85, 65, 95];

function buildPath(data: number[], width: number, height: number, progress: number): string {
  'worklet';
  const maxVal = Math.max(...data);
  const stepX = (width - PADDING * 2) / (data.length - 1);
  const visiblePoints = Math.floor(progress * data.length);

  if (visiblePoints < 2) return '';

  let path = '';
  for (let i = 0; i < visiblePoints; i++) {
    const x = PADDING + i * stepX;
    const y = height - PADDING - (data[i] / maxVal) * (height - PADDING * 2);
    if (i === 0) {
      path += `M ${x} ${y}`;
    } else {
      // Smooth curve using quadratic bezier
      const prevX = PADDING + (i - 1) * stepX;
      const prevY = height - PADDING - (data[i - 1] / maxVal) * (height - PADDING * 2);
      const cpX = (prevX + x) / 2;
      path += ` Q ${cpX} ${prevY} ${x} ${y}`;
    }
  }
  return path;
}

export function AnimatedGraph() {
  const progress = useSharedValue(0);

  useEffect(() => {
    progress.value = withTiming(1, {
      duration: 1500,
      easing: Easing.out(Easing.cubic),
    });
  }, []);

  const animatedPath = useDerivedValue(() => {
    return buildPath(DATA, GRAPH_WIDTH, GRAPH_HEIGHT, progress.value);
  });

  const lastPointX = useDerivedValue(() => {
    const visiblePoints = Math.floor(progress.value * DATA.length);
    if (visiblePoints < 1) return 0;
    const stepX = (GRAPH_WIDTH - PADDING * 2) / (DATA.length - 1);
    return PADDING + (visiblePoints - 1) * stepX;
  });

  const lastPointY = useDerivedValue(() => {
    const visiblePoints = Math.floor(progress.value * DATA.length);
    if (visiblePoints < 1) return 0;
    const maxVal = Math.max(...DATA);
    return GRAPH_HEIGHT - PADDING - (DATA[visiblePoints - 1] / maxVal) * (GRAPH_HEIGHT - PADDING * 2);
  });

  return (
    <View style={styles.graphContainer}>
      <Canvas style={{ width: GRAPH_WIDTH, height: GRAPH_HEIGHT }}>
        <SkiaPath
          path={animatedPath}
          style="stroke"
          strokeWidth={3}
          strokeCap="round"
          strokeJoin="round"
          color="#6366f1"
        />
        <Circle cx={lastPointX} cy={lastPointY} r={5} color="#6366f1" />
      </Canvas>
    </View>
  );
}

const styles = StyleSheet.create({
  graphContainer: {
    alignItems: 'center',
    padding: 16,
    backgroundColor: '#fff',
    borderRadius: 16,
    margin: 16,
  },
});

For production charts, consider react-native-graph (by Margelo) or victory-native, both of which use Skia internally and handle axes, labels, and gestures. Building from scratch with Skia is appropriate when you need full visual control or non-standard chart types.

Performance Considerations for Skia

  • Skia runs on the GPU. Drawing many small elements (hundreds of circles, complex paths) increases GPU load but frees the CPU.
  • Avoid re-creating Skia.Path objects every frame. Build the path once and animate transform or clip properties.
  • Canvas allocates an offscreen surface. Multiple overlapping canvases multiply memory usage.
  • Use Picture for static complex drawings -- it records drawing commands once and replays them.
  • Profile with Xcode GPU debugger (iOS) or Android GPU Inspector for overdraw analysis.

Performance Optimization

Verify Worklets Run on the UI Thread

If a function called inside useAnimatedStyle is not a worklet, it silently falls back to the JS thread, killing performance. Add the 'worklet' directive explicitly when the automatic inference is uncertain.

// This helper must be marked as a worklet to run on the UI thread
function clampValue(val: number, min: number, max: number): number {
  'worklet';
  return Math.min(Math.max(val, min), max);
}

useAnimatedReaction for Side Effects

When you need to trigger a side effect (haptic feedback, sound, JS-thread callback) based on a shared value change:

import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
import * as Haptics from 'expo-haptics';

function triggerHaptic() {
  Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}

useAnimatedReaction(
  () => Math.round(progress.value * 10), // derived value (granularity)
  (current, previous) => {
    if (current !== previous) {
      runOnJS(triggerHaptic)();
    }
  },
);

cancelAnimation for Cleanup

Always cancel running animations when a component unmounts or when starting a new animation on the same shared value.

import { cancelAnimation } from 'react-native-reanimated';

useEffect(() => {
  progress.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true);
  return () => {
    cancelAnimation(progress);
  };
}, []);

Measuring Animation Performance

ToolWhat to Check
In-app Performance MonitorJS thread FPS and UI thread FPS separately
Flipper Performance PluginFrame timing breakdown
console.log in workletShould print [reanimated] prefix (confirms UI thread)
Xcode Instruments (Core Animation)Offscreen rendering, blending, frame drops
Android GPU ProfilerOverdraw, frame rendering time

Common Performance Pitfalls

// BAD: too many shared values per component (10+ becomes expensive)
const sv1 = useSharedValue(0);
const sv2 = useSharedValue(0);
// ... sv3 through sv20
// BETTER: group related values into a single shared value object
const state = useSharedValue({ x: 0, y: 0, scale: 1, rotation: 0 });

// BAD: heavy computation inside a worklet
const style = useAnimatedStyle(() => {
  // Sorting a 1000-element array 60 times per second
  const sorted = bigArray.sort((a, b) => a - b); // DO NOT do this
  return { opacity: sorted[0] };
});

// BAD: reading .value on every render (triggers re-render)
function Component() {
  const sv = useSharedValue(0);
  console.log(sv.value); // reading on JS thread re-renders
  return <Animated.View />;
}

Testing Animations

Jest Setup for Reanimated

Reanimated requires a mock to run in Jest because worklets depend on native modules.

// jest.setup.js
require('react-native-reanimated').setUpTests();

// jest.config.js
module.exports = {
  preset: 'react-native',
  setupFilesAfterSetup: ['./jest.setup.js'],
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|react-native-reanimated|react-native-gesture-handler)/)',
  ],
};

Testing Animated Component States

With the mock, shared values update synchronously, so you can test final states:

import { render, fireEvent } from '@testing-library/react-native';
import { AnimatedCard } from './AnimatedCard';
import { getAnimatedStyle } from 'react-native-reanimated';

test('card scales down on press', () => {
  const { getByTestId } = render(<AnimatedCard />);
  const card = getByTestId('animated-card');

  fireEvent(card, 'pressIn');

  const style = getAnimatedStyle(card);
  expect(style.transform).toContainEqual(
    expect.objectContaining({ scale: expect.closeTo(0.95, 1) }),
  );
});

Snapshot Testing Limitations

Snapshots capture the React tree structure but not animated style values (those live on the native side). Snapshots are useful for verifying component structure but should not be relied on for animation correctness.

Visual Regression Testing

For true animation verification, use screenshot-based testing:

  • Detox: Run E2E tests that trigger gestures and capture screenshots at key frames.
  • Appium + Applitools: Visual regression with AI-based diff detection.
  • Maestro: Simpler E2E framework with screenshot assertion support.
# maestro/test-animation.yaml
appId: com.myapp
---
- launchApp
- tapOn: "Animated Card"
- assertVisible: "Press Me"
- longPressOn: "Press Me"
- takeScreenshot: card-pressed-state

Anti-Patterns

Using Animated API for Complex Gestures

The built-in Animated API with useNativeDriver is limited to opacity and transforms. For anything gesture-driven or involving multiple properties, use Reanimated.

// WRONG: Animated API cannot handle this natively
Animated.timing(backgroundColor, {
  toValue: 1,
  useNativeDriver: true, // crashes at runtime
}).start();

// RIGHT: Reanimated handles any property
bgProgress.value = withTiming(1);
const style = useAnimatedStyle(() => ({
  backgroundColor: interpolateColor(bgProgress.value, [0, 1], ['#fff', '#000']),
}));

Creating Shared Values Inside Render

// WRONG: shared value is recreated every render, losing animation state
function BadComponent() {
  const opacity = useSharedValue(1); // fine as a hook...
  return items.map((item) => {
    const scale = useSharedValue(1); // WRONG: hooks in a loop
    return <Animated.View key={item.id} />;
  });
}

// RIGHT: use a ref map or create shared values at the list-item level
function GoodItem({ item }: { item: Item }) {
  const scale = useSharedValue(1); // one per component instance
  return <Animated.View />;
}

Heavy Computation in Worklets

Worklets run on the UI thread. Blocking the UI thread with expensive computation drops frames just as badly as blocking the JS thread.

// WRONG: expensive work in a worklet
const style = useAnimatedStyle(() => {
  // Parsing JSON, complex math, or large array operations
  const result = expensiveCalculation(data.value);
  return { opacity: result };
});

// RIGHT: do heavy work on JS thread, send result via shared value
useEffect(() => {
  const result = expensiveCalculation(data);
  computedResult.value = result;
}, [data]);

const style = useAnimatedStyle(() => ({
  opacity: computedResult.value,
}));

Animating Layout Properties Without LayoutAnimation

Changing width, height, margin, or padding triggers a full layout recalculation. Without Reanimated's layout animation system, this causes visible jank.

// WRONG: raw state change causes layout jump
const [expanded, setExpanded] = useState(false);
<View style={{ height: expanded ? 300 : 0 }} /> // snaps, no animation

// RIGHT: use Reanimated's layout animation or animated height
const height = useSharedValue(0);
const style = useAnimatedStyle(() => ({
  height: height.value,
  overflow: 'hidden',
}));
// Then: height.value = withTiming(expanded ? 300 : 0);

Not Cleaning Up Animations on Unmount

Animations that continue after a component unmounts write to deallocated shared values. While Reanimated handles this gracefully in most cases, it wastes CPU cycles and can cause warnings.

// WRONG: no cleanup
useEffect(() => {
  offset.value = withRepeat(withTiming(100, { duration: 1000 }), -1, true);
}, []);

// RIGHT: cancel on unmount
useEffect(() => {
  offset.value = withRepeat(withTiming(100, { duration: 1000 }), -1, true);
  return () => cancelAnimation(offset);
}, []);

When using runOnJS inside gesture handlers or useAnimatedReaction, ensure the target function is defined outside the component render body or wrapped in useCallback. Otherwise, the worklet captures a stale closure reference.

On this page