Skip to main content

Module 9: Animations and Gestures

Layout Animations

Animate components as they enter, exit, and transition within your layouts

🎯 Learning Objectives

  • Understand layout animations and when to use them
  • Implement entering animations for mounting components
  • Create exiting animations for unmounting components
  • Use layout transitions for smooth position changes
  • Build custom keyframe animations
  • Apply layout animations to lists and dynamic content

Introduction to Layout Animations

Layout animations bring your UI to life by animating components as they appear, disappear, or change position. Unlike imperative animations where you manually control values, layout animations are declarative—you specify what should happen, and Reanimated handles the how.

Types of Layout Animations

flowchart LR
    subgraph Entering["Entering"]
        A[Component mounts]
        B[Animate in]
    end
    
    subgraph Layout["Layout"]
        C[Position changes]
        D[Animate to new position]
    end
    
    subgraph Exiting["Exiting"]
        E[Component unmounts]
        F[Animate out first]
    end
    
    Entering --> Layout --> Exiting
    
    style Entering fill:#e8f5e9
    style Layout fill:#e3f2fd
    style Exiting fill:#ffebee

When to Use Layout Animations

Scenario Animation Type Example
New item appears Entering Toast notification slides in
Item is removed Exiting Deleted list item fades out
List reorders Layout Items smoothly shift positions
Size changes Layout Accordion expands/collapses
Screen transition Entering + Exiting Page content fades between screens

Basic Usage Pattern

import Animated, {
  FadeIn,
  FadeOut,
  Layout,
} from 'react-native-reanimated';

function AnimatedComponent({ visible }: { visible: boolean }) {
  if (!visible) return null;
  
  return (
    <Animated.View
      entering={FadeIn}          // Animation when mounting
      exiting={FadeOut}          // Animation when unmounting
      layout={Layout.springify()} // Animation when position changes
      style={styles.box}
    >
      <Text>Hello!</Text>
    </Animated.View>
  );
}

💡 Key Concept

Layout animations are applied as props to Animated.View components. They automatically trigger based on component lifecycle:

  • entering: Runs when component mounts
  • exiting: Runs when component unmounts (delays removal)
  • layout: Runs when layout position or size changes

Entering Animations

Entering animations run when a component mounts. Reanimated provides many built-in presets that you can use directly or customize.

Built-in Entering Animations

import Animated, {
  // Fade animations
  FadeIn,
  FadeInUp,
  FadeInDown,
  FadeInLeft,
  FadeInRight,
  
  // Slide animations
  SlideInUp,
  SlideInDown,
  SlideInLeft,
  SlideInRight,
  
  // Zoom animations
  ZoomIn,
  ZoomInUp,
  ZoomInDown,
  ZoomInLeft,
  ZoomInRight,
  ZoomInRotate,
  ZoomInEasyUp,
  ZoomInEasyDown,
  
  // Bounce animations
  BounceIn,
  BounceInUp,
  BounceInDown,
  BounceInLeft,
  BounceInRight,
  
  // Flip animations
  FlipInXUp,
  FlipInXDown,
  FlipInYLeft,
  FlipInYRight,
  FlipInEasyX,
  FlipInEasyY,
  
  // Stretch animations
  StretchInX,
  StretchInY,
  
  // LightSpeed animations
  LightSpeedInLeft,
  LightSpeedInRight,
  
  // Pinwheel
  PinwheelIn,
  
  // Roll
  RollInLeft,
  RollInRight,
  
  // Rotate
  RotateInUpLeft,
  RotateInUpRight,
  RotateInDownLeft,
  RotateInDownRight,
} from 'react-native-reanimated';

// Simple usage
function EnteringExample() {
  return (
    <Animated.View entering={FadeInUp}>
      <Text>I fade in from above!</Text>
    </Animated.View>
  );
}

Customizing Entering Animations

import Animated, {
  FadeIn,
  SlideInRight,
  ZoomIn,
  BounceIn,
} from 'react-native-reanimated';

function CustomEnteringExample() {
  return (
    <View>
      {/* Custom duration */}
      <Animated.View entering={FadeIn.duration(800)}>
        <Text>Slow fade in (800ms)</Text>
      </Animated.View>
      
      {/* Custom delay */}
      <Animated.View entering={SlideInRight.delay(300)}>
        <Text>Delayed slide in</Text>
      </Animated.View>
      
      {/* Spring physics */}
      <Animated.View entering={ZoomIn.springify()}>
        <Text>Bouncy zoom in</Text>
      </Animated.View>
      
      {/* Custom spring config */}
      <Animated.View 
        entering={BounceIn.springify()
          .damping(12)
          .stiffness(100)
          .mass(0.5)}
      >
        <Text>Custom spring bounce</Text>
      </Animated.View>
      
      {/* Chained customizations */}
      <Animated.View 
        entering={FadeIn
          .delay(200)
          .duration(500)
          .withInitialValues({ opacity: 0.5 })}
      >
        <Text>Start from 50% opacity</Text>
      </Animated.View>
    </View>
  );
}

Staggered Entering

import Animated, { FadeInUp } from 'react-native-reanimated';

function StaggeredList() {
  const items = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'];
  
  return (
    <View>
      {items.map((item, index) => (
        <Animated.View
          key={item}
          entering={FadeInUp.delay(index * 100).springify()}
          style={styles.listItem}
        >
          <Text>{item}</Text>
        </Animated.View>
      ))}
    </View>
  );
}

Callback on Animation Complete

import Animated, { FadeIn, runOnJS } from 'react-native-reanimated';

function CallbackExample() {
  const [animationComplete, setAnimationComplete] = useState(false);
  
  const handleAnimationComplete = () => {
    setAnimationComplete(true);
    console.log('Entering animation finished!');
  };
  
  return (
    <Animated.View
      entering={FadeIn.duration(500).withCallback((finished) => {
        'worklet';
        if (finished) {
          runOnJS(handleAnimationComplete)();
        }
      })}
    >
      <Text>Watch for callback</Text>
    </Animated.View>
  );
}

Custom Entering Animation

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

// Define a custom entering animation
const CustomEntering = (targetValues) => {
  'worklet';
  const animations = {
    opacity: withTiming(1, { duration: 500 }),
    transform: [
      { translateY: withSpring(0, { damping: 15 }) },
      { scale: withSpring(1, { damping: 12 }) },
      { rotate: withTiming('0deg', { duration: 400 }) },
    ],
  };
  
  const initialValues = {
    opacity: 0,
    transform: [
      { translateY: 100 },
      { scale: 0.5 },
      { rotate: '-45deg' },
    ],
  };
  
  return {
    initialValues,
    animations,
  };
};

// Use the custom animation
function CustomEnteringExample() {
  return (
    <Animated.View entering={CustomEntering} style={styles.box}>
      <Text>Custom animation!</Text>
    </Animated.View>
  );
}

Entering Animation Reference

Category Animations Best For
Fade FadeIn, FadeInUp/Down/Left/Right Subtle, elegant appearance
Slide SlideInUp/Down/Left/Right Drawers, sheets, menus
Zoom ZoomIn, ZoomInRotate Modals, popups, emphasis
Bounce BounceIn, BounceInUp/Down Playful UI, notifications
Flip FlipInX/Y, FlipInEasyX/Y Card reveals, transitions

Exiting Animations

Exiting animations run when a component unmounts. They delay the actual removal until the animation completes, creating smooth transitions.

Built-in Exiting Animations

import Animated, {
  // Fade animations
  FadeOut,
  FadeOutUp,
  FadeOutDown,
  FadeOutLeft,
  FadeOutRight,
  
  // Slide animations
  SlideOutUp,
  SlideOutDown,
  SlideOutLeft,
  SlideOutRight,
  
  // Zoom animations
  ZoomOut,
  ZoomOutUp,
  ZoomOutDown,
  ZoomOutLeft,
  ZoomOutRight,
  ZoomOutRotate,
  ZoomOutEasyUp,
  ZoomOutEasyDown,
  
  // Bounce animations
  BounceOut,
  BounceOutUp,
  BounceOutDown,
  BounceOutLeft,
  BounceOutRight,
  
  // Flip animations
  FlipOutXUp,
  FlipOutXDown,
  FlipOutYLeft,
  FlipOutYRight,
  FlipOutEasyX,
  FlipOutEasyY,
  
  // Other
  StretchOutX,
  StretchOutY,
  LightSpeedOutLeft,
  LightSpeedOutRight,
  PinwheelOut,
  RollOutLeft,
  RollOutRight,
  RotateOutUpLeft,
  RotateOutUpRight,
  RotateOutDownLeft,
  RotateOutDownRight,
} from 'react-native-reanimated';

function ExitingExample() {
  const [visible, setVisible] = useState(true);
  
  return (
    <View>
      <Pressable onPress={() => setVisible(!visible)}>
        <Text>Toggle</Text>
      </Pressable>
      
      {visible && (
        <Animated.View
          entering={FadeIn}
          exiting={FadeOutDown.duration(300)}
          style={styles.box}
        >
          <Text>I fade out downward!</Text>
        </Animated.View>
      )}
    </View>
  );
}

Matching Entering and Exiting

import Animated, {
  SlideInRight,
  SlideOutRight,
  ZoomIn,
  ZoomOut,
  FadeInUp,
  FadeOutDown,
} from 'react-native-reanimated';

function MatchedAnimations() {
  const [items, setItems] = useState(['a', 'b', 'c']);
  
  const addItem = () => {
    setItems([...items, Date.now().toString()]);
  };
  
  const removeItem = (id: string) => {
    setItems(items.filter(item => item !== id));
  };
  
  return (
    <View>
      {items.map((item) => (
        <Animated.View
          key={item}
          entering={SlideInRight.springify()}
          exiting={SlideOutRight.duration(200)}
          style={styles.item}
        >
          <Text>{item}</Text>
          <Pressable onPress={() => removeItem(item)}>
            <Text>×</Text>
          </Pressable>
        </Animated.View>
      ))}
      
      <Pressable onPress={addItem}>
        <Text>Add Item</Text>
      </Pressable>
    </View>
  );
}

Custom Exiting Animation

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

const CustomExiting = (values) => {
  'worklet';
  const animations = {
    opacity: withTiming(0, { duration: 300 }),
    transform: [
      { translateX: withTiming(values.currentWidth, { duration: 300 }) },
      { scale: withTiming(0.8, { duration: 300 }) },
      { rotate: withTiming('15deg', { duration: 300 }) },
    ],
  };
  
  const initialValues = {
    opacity: 1,
    transform: [
      { translateX: 0 },
      { scale: 1 },
      { rotate: '0deg' },
    ],
  };
  
  return {
    initialValues,
    animations,
  };
};

function CustomExitExample() {
  const [visible, setVisible] = useState(true);
  
  return (
    <View>
      {visible && (
        <Animated.View
          entering={FadeIn}
          exiting={CustomExiting}
          style={styles.box}
        >
          <Text>Custom exit animation!</Text>
        </Animated.View>
      )}
    </View>
  );
}

⚠️ Important Notes

  • Exiting animations only work when the component is conditionally rendered
  • The component must have a unique key prop for proper tracking
  • Parent components must be Animated.View or wrapped properly
  • Exiting delays actual unmount—don't rely on immediate cleanup

Layout Transitions

Layout transitions animate components when their position or size changes within the layout. This creates fluid, natural-feeling interfaces where elements smoothly shift to accommodate changes.

Basic Layout Transition

import Animated, { Layout } from 'react-native-reanimated';

function LayoutTransitionExample() {
  const [expanded, setExpanded] = useState(false);
  
  return (
    <View>
      <Pressable onPress={() => setExpanded(!expanded)}>
        <Animated.View
          layout={Layout.springify()}
          style={[
            styles.box,
            expanded && styles.expandedBox,
          ]}
        >
          <Text>{expanded ? 'Expanded' : 'Tap to expand'}</Text>
        </Animated.View>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  box: {
    width: 100,
    height: 100,
    backgroundColor: '#007AFF',
    justifyContent: 'center',
    alignItems: 'center',
  },
  expandedBox: {
    width: 200,
    height: 200,
  },
});

Layout Animation Types

import Animated, {
  Layout,
  LinearTransition,
  SequencedTransition,
  FadingTransition,
  JumpingTransition,
  CurvedTransition,
  EntryExitTransition,
  Easing,
} from 'react-native-reanimated';

function LayoutTypesExample() {
  return (
    <View>
      {/* Default spring-based layout */}
      <Animated.View layout={Layout}>
        <Text>Default Layout</Text>
      </Animated.View>
      
      {/* Spring with customization */}
      <Animated.View 
        layout={Layout.springify().damping(15).stiffness(100)}
      >
        <Text>Springy Layout</Text>
      </Animated.View>
      
      {/* Linear timing-based */}
      <Animated.View layout={LinearTransition.duration(300)}>
        <Text>Linear Transition</Text>
      </Animated.View>
      
      {/* With easing */}
      <Animated.View 
        layout={LinearTransition
          .duration(400)
          .easing(Easing.bezier(0.25, 0.1, 0.25, 1))}
      >
        <Text>Eased Transition</Text>
      </Animated.View>
      
      {/* Sequenced (width then height) */}
      <Animated.View layout={SequencedTransition}>
        <Text>Sequenced Transition</Text>
      </Animated.View>
      
      {/* Fading transition */}
      <Animated.View layout={FadingTransition}>
        <Text>Fading Transition</Text>
      </Animated.View>
      
      {/* Jumping transition */}
      <Animated.View layout={JumpingTransition}>
        <Text>Jumping Transition</Text>
      </Animated.View>
      
      {/* Curved transition */}
      <Animated.View layout={CurvedTransition}>
        <Text>Curved Transition</Text>
      </Animated.View>
    </View>
  );
}

Reordering Items

import React, { useState } from 'react';
import { StyleSheet, View, Text, Pressable } from 'react-native';
import Animated, { Layout, FadeIn, FadeOut } from 'react-native-reanimated';

interface Item {
  id: string;
  title: string;
  color: string;
}

function ReorderingList() {
  const [items, setItems] = useState<Item[]>([
    { id: '1', title: 'Red', color: '#FF3B30' },
    { id: '2', title: 'Orange', color: '#FF9500' },
    { id: '3', title: 'Yellow', color: '#FFCC00' },
    { id: '4', title: 'Green', color: '#34C759' },
    { id: '5', title: 'Blue', color: '#007AFF' },
  ]);
  
  const shuffle = () => {
    setItems([...items].sort(() => Math.random() - 0.5));
  };
  
  const reverse = () => {
    setItems([...items].reverse());
  };
  
  return (
    <View style={styles.container}>
      <View style={styles.buttons}>
        <Pressable style={styles.button} onPress={shuffle}>
          <Text style={styles.buttonText}>Shuffle</Text>
        </Pressable>
        <Pressable style={styles.button} onPress={reverse}>
          <Text style={styles.buttonText}>Reverse</Text>
        </Pressable>
      </View>
      
      <View style={styles.list}>
        {items.map((item) => (
          <Animated.View
            key={item.id}
            layout={Layout.springify().damping(15)}
            style={[styles.item, { backgroundColor: item.color }]}
          >
            <Text style={styles.itemText}>{item.title}</Text>
          </Animated.View>
        ))}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
  },
  buttons: {
    flexDirection: 'row',
    marginBottom: 20,
    gap: 10,
  },
  button: {
    backgroundColor: '#333',
    paddingVertical: 12,
    paddingHorizontal: 20,
    borderRadius: 8,
  },
  buttonText: {
    color: 'white',
    fontWeight: '600',
  },
  list: {
    gap: 10,
  },
  item: {
    padding: 20,
    borderRadius: 12,
    alignItems: 'center',
  },
  itemText: {
    color: 'white',
    fontSize: 18,
    fontWeight: '600',
  },
});

Accordion with Layout Animation

import React, { useState } from 'react';
import { StyleSheet, View, Text, Pressable } from 'react-native';
import Animated, { 
  Layout, 
  FadeIn, 
  FadeOut,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

interface AccordionProps {
  title: string;
  children: React.ReactNode;
}

function Accordion({ title, children }: AccordionProps) {
  const [expanded, setExpanded] = useState(false);
  const rotation = useSharedValue(0);
  
  const toggle = () => {
    setExpanded(!expanded);
    rotation.value = withTiming(expanded ? 0 : 90, { duration: 200 });
  };
  
  const chevronStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${rotation.value}deg` }],
  }));
  
  return (
    <Animated.View layout={Layout.springify()} style={styles.accordion}>
      <Pressable onPress={toggle} style={styles.header}>
        <Text style={styles.title}>{title}</Text>
        <Animated.Text style={[styles.chevron, chevronStyle]}>
          ▶
        </Animated.Text>
      </Pressable>
      
      {expanded && (
        <Animated.View
          entering={FadeIn.duration(200)}
          exiting={FadeOut.duration(200)}
          style={styles.content}
        >
          {children}
        </Animated.View>
      )}
    </Animated.View>
  );
}

function AccordionExample() {
  return (
    <View style={styles.container}>
      <Accordion title="Section 1">
        <Text>Content for section 1. This can be any length.</Text>
      </Accordion>
      
      <Accordion title="Section 2">
        <Text>Content for section 2. 
          Lorem ipsum dolor sit amet, consectetur adipiscing elit.
          Sed do eiusmod tempor incididunt ut labore.
        </Text>
      </Accordion>
      
      <Accordion title="Section 3">
        <Text>Short content.</Text>
      </Accordion>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
    gap: 8,
  },
  accordion: {
    backgroundColor: 'white',
    borderRadius: 12,
    overflow: 'hidden',
  },
  header: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    padding: 16,
  },
  title: {
    fontSize: 16,
    fontWeight: '600',
  },
  chevron: {
    fontSize: 12,
    color: '#666',
  },
  content: {
    padding: 16,
    paddingTop: 0,
  },
});

Grid Layout Animation

import React, { useState } from 'react';
import { StyleSheet, View, Pressable, Text, Dimensions } from 'react-native';
import Animated, { Layout, FadeIn, FadeOut } from 'react-native-reanimated';

const { width } = Dimensions.get('window');
const COLUMNS = 3;
const GAP = 10;
const ITEM_SIZE = (width - 40 - (COLUMNS - 1) * GAP) / COLUMNS;

function AnimatedGrid() {
  const [items, setItems] = useState(
    Array.from({ length: 9 }, (_, i) => ({
      id: `item-${i}`,
      visible: true,
    }))
  );
  
  const toggleItem = (id: string) => {
    setItems(items.map(item =>
      item.id === id ? { ...item, visible: !item.visible } : item
    ));
  };
  
  const visibleItems = items.filter(item => item.visible);
  
  return (
    <View style={styles.container}>
      <View style={styles.grid}>
        {visibleItems.map((item) => (
          <Animated.View
            key={item.id}
            layout={Layout.springify().damping(15)}
            entering={FadeIn.springify()}
            exiting={FadeOut.duration(200)}
          >
            <Pressable
              style={styles.gridItem}
              onPress={() => toggleItem(item.id)}
            >
              <Text style={styles.itemText}>×</Text>
            </Pressable>
          </Animated.View>
        ))}
      </View>
      
      <Pressable
        style={styles.resetButton}
        onPress={() => setItems(items.map(item => ({ ...item, visible: true })))}
      >
        <Text style={styles.resetText}>Reset All</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
  },
  grid: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: GAP,
  },
  gridItem: {
    width: ITEM_SIZE,
    height: ITEM_SIZE,
    backgroundColor: '#007AFF',
    borderRadius: 12,
    justifyContent: 'center',
    alignItems: 'center',
  },
  itemText: {
    color: 'white',
    fontSize: 24,
  },
  resetButton: {
    marginTop: 20,
    backgroundColor: '#34C759',
    padding: 16,
    borderRadius: 12,
    alignItems: 'center',
  },
  resetText: {
    color: 'white',
    fontWeight: '600',
  },
});

Keyframe Animations

Keyframe animations let you define multi-step animations with precise control over timing and values at each step.

Basic Keyframe Animation

import Animated, { Keyframe } from 'react-native-reanimated';

// Define a keyframe animation
const bounceKeyframe = new Keyframe({
  0: {
    transform: [{ scale: 0 }],
    opacity: 0,
  },
  25: {
    transform: [{ scale: 1.2 }],
    opacity: 1,
  },
  50: {
    transform: [{ scale: 0.9 }],
  },
  75: {
    transform: [{ scale: 1.1 }],
  },
  100: {
    transform: [{ scale: 1 }],
  },
}).duration(800);

function KeyframeExample() {
  const [visible, setVisible] = useState(true);
  
  return (
    <View>
      <Pressable onPress={() => setVisible(!visible)}>
        <Text>Toggle</Text>
      </Pressable>
      
      {visible && (
        <Animated.View
          entering={bounceKeyframe}
          style={styles.box}
        >
          <Text>Bounce In!</Text>
        </Animated.View>
      )}
    </View>
  );
}

Complex Keyframe Animations

import Animated, { Keyframe, Easing } from 'react-native-reanimated';

// Shake animation
const shakeKeyframe = new Keyframe({
  0: { transform: [{ translateX: 0 }] },
  10: { transform: [{ translateX: -10 }] },
  20: { transform: [{ translateX: 10 }] },
  30: { transform: [{ translateX: -10 }] },
  40: { transform: [{ translateX: 10 }] },
  50: { transform: [{ translateX: -5 }] },
  60: { transform: [{ translateX: 5 }] },
  70: { transform: [{ translateX: -2 }] },
  80: { transform: [{ translateX: 2 }] },
  100: { transform: [{ translateX: 0 }] },
}).duration(500);

// Attention pulse
const pulseKeyframe = new Keyframe({
  0: { 
    transform: [{ scale: 1 }],
    opacity: 1,
  },
  50: { 
    transform: [{ scale: 1.05 }],
    opacity: 0.8,
  },
  100: { 
    transform: [{ scale: 1 }],
    opacity: 1,
  },
}).duration(1000);

// Swing animation
const swingKeyframe = new Keyframe({
  0: { transform: [{ rotate: '0deg' }] },
  20: { transform: [{ rotate: '15deg' }] },
  40: { transform: [{ rotate: '-10deg' }] },
  60: { transform: [{ rotate: '5deg' }] },
  80: { transform: [{ rotate: '-5deg' }] },
  100: { transform: [{ rotate: '0deg' }] },
}).duration(800);

// Flip animation
const flipKeyframe = new Keyframe({
  0: {
    transform: [{ perspective: 400 }, { rotateY: '0deg' }],
    opacity: 1,
  },
  40: {
    transform: [{ perspective: 400 }, { rotateY: '-180deg' }],
    opacity: 0,
  },
  60: {
    transform: [{ perspective: 400 }, { rotateY: '-180deg' }],
    opacity: 0,
  },
  100: {
    transform: [{ perspective: 400 }, { rotateY: '-360deg' }],
    opacity: 1,
  },
}).duration(1000);

// Heartbeat animation
const heartbeatKeyframe = new Keyframe({
  0: { transform: [{ scale: 1 }] },
  14: { transform: [{ scale: 1.3 }] },
  28: { transform: [{ scale: 1 }] },
  42: { transform: [{ scale: 1.3 }] },
  70: { transform: [{ scale: 1 }] },
  100: { transform: [{ scale: 1 }] },
}).duration(1300);

Keyframe with Easing

import Animated, { Keyframe, Easing } from 'react-native-reanimated';

// Keyframe with custom easing per segment
const elasticEnterKeyframe = new Keyframe({
  0: {
    transform: [{ translateY: -100 }, { scale: 0 }],
    opacity: 0,
    easing: Easing.out(Easing.exp),
  },
  60: {
    transform: [{ translateY: 20 }, { scale: 1.1 }],
    opacity: 1,
    easing: Easing.out(Easing.bounce),
  },
  100: {
    transform: [{ translateY: 0 }, { scale: 1 }],
    opacity: 1,
  },
}).duration(1000);

// Delayed keyframe
const delayedBounceKeyframe = new Keyframe({
  0: {
    transform: [{ translateY: 0 }],
  },
  50: {
    transform: [{ translateY: 0 }],
  },
  65: {
    transform: [{ translateY: -30 }],
  },
  80: {
    transform: [{ translateY: 0 }],
  },
  90: {
    transform: [{ translateY: -15 }],
  },
  100: {
    transform: [{ translateY: 0 }],
  },
}).duration(1500);

Reusable Keyframe Components

import React from 'react';
import Animated, { Keyframe } from 'react-native-reanimated';

// Define animation keyframes
const animations = {
  fadeInUp: new Keyframe({
    0: {
      opacity: 0,
      transform: [{ translateY: 30 }],
    },
    100: {
      opacity: 1,
      transform: [{ translateY: 0 }],
    },
  }).duration(400),
  
  fadeOutDown: new Keyframe({
    0: {
      opacity: 1,
      transform: [{ translateY: 0 }],
    },
    100: {
      opacity: 0,
      transform: [{ translateY: 30 }],
    },
  }).duration(300),
  
  popIn: new Keyframe({
    0: {
      transform: [{ scale: 0 }],
      opacity: 0,
    },
    70: {
      transform: [{ scale: 1.1 }],
      opacity: 1,
    },
    100: {
      transform: [{ scale: 1 }],
      opacity: 1,
    },
  }).duration(350),
};

// Reusable animated wrapper
interface AnimatedItemProps {
  animation?: keyof typeof animations;
  delay?: number;
  children: React.ReactNode;
}

function AnimatedItem({ 
  animation = 'fadeInUp', 
  delay = 0, 
  children 
}: AnimatedItemProps) {
  const keyframe = animations[animation].delay(delay);
  
  return (
    <Animated.View entering={keyframe}>
      {children}
    </Animated.View>
  );
}

// Usage
function AnimatedList() {
  return (
    <View>
      <AnimatedItem delay={0}>
        <Text>First</Text>
      </AnimatedItem>
      <AnimatedItem delay={100}>
        <Text>Second</Text>
      </AnimatedItem>
      <AnimatedItem animation="popIn" delay={200}>
        <Text>Third (pop)</Text>
      </AnimatedItem>
    </View>
  );
}

💡 Keyframe Tips

  • Percentages (0-100) define timing within the total duration
  • You can skip percentages—intermediate values are interpolated
  • Add easing to individual keyframes for segment-specific easing
  • Use .delay() to add a delay before the animation starts
  • Chain multiple keyframes with .withCallback() for notifications

Animating Lists

Lists are one of the most common places to apply layout animations. Whether using FlatList, ScrollView, or simple maps, animations make list interactions feel polished and responsive.

Animated FlatList Items

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

interface ListItem {
  id: string;
  title: string;
}

function AnimatedFlatList() {
  const [items, setItems] = useState<ListItem[]>([
    { id: '1', title: 'First Item' },
    { id: '2', title: 'Second Item' },
    { id: '3', title: 'Third Item' },
  ]);
  
  const addItem = () => {
    const newItem = {
      id: Date.now().toString(),
      title: `Item ${items.length + 1}`,
    };
    setItems([newItem, ...items]);
  };
  
  const removeItem = (id: string) => {
    setItems(items.filter(item => item.id !== id));
  };
  
  const renderItem = useCallback(({ item, index }: { item: ListItem; index: number }) => (
    <Animated.View
      entering={FadeInRight.delay(index * 50).springify()}
      exiting={FadeOutLeft.duration(200)}
      layout={Layout.springify()}
      style={styles.item}
    >
      <Text style={styles.itemText}>{item.title}</Text>
      <Pressable
        style={styles.deleteButton}
        onPress={() => removeItem(item.id)}
      >
        <Text style={styles.deleteText}>Delete</Text>
      </Pressable>
    </Animated.View>
  ), []);
  
  return (
    <View style={styles.container}>
      <Pressable style={styles.addButton} onPress={addItem}>
        <Text style={styles.addText}>Add Item</Text>
      </Pressable>
      
      <FlatList
        data={items}
        renderItem={renderItem}
        keyExtractor={(item) => item.id}
        contentContainerStyle={styles.list}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
  },
  addButton: {
    backgroundColor: '#34C759',
    padding: 16,
    borderRadius: 12,
    alignItems: 'center',
    marginBottom: 16,
  },
  addText: {
    color: 'white',
    fontWeight: '600',
    fontSize: 16,
  },
  list: {
    gap: 8,
  },
  item: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    backgroundColor: 'white',
    padding: 16,
    borderRadius: 12,
  },
  itemText: {
    fontSize: 16,
  },
  deleteButton: {
    backgroundColor: '#FF3B30',
    paddingVertical: 8,
    paddingHorizontal: 12,
    borderRadius: 8,
  },
  deleteText: {
    color: 'white',
    fontWeight: '600',
  },
});

Staggered List with Different Animations

import React, { useState, useEffect } from 'react';
import { StyleSheet, View, Text, ScrollView } from 'react-native';
import Animated, {
  FadeInDown,
  FadeInLeft,
  FadeInRight,
  ZoomIn,
  BounceIn,
  SlideInRight,
} from 'react-native-reanimated';

// Different animations for variety
const animations = [
  FadeInDown,
  FadeInLeft,
  FadeInRight,
  ZoomIn,
  BounceIn,
  SlideInRight,
];

interface CardData {
  id: string;
  title: string;
  description: string;
}

function StaggeredCards() {
  const [cards, setCards] = useState<CardData[]>([]);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    // Simulate data fetch
    setTimeout(() => {
      setCards([
        { id: '1', title: 'Card 1', description: 'First card description' },
        { id: '2', title: 'Card 2', description: 'Second card description' },
        { id: '3', title: 'Card 3', description: 'Third card description' },
        { id: '4', title: 'Card 4', description: 'Fourth card description' },
        { id: '5', title: 'Card 5', description: 'Fifth card description' },
        { id: '6', title: 'Card 6', description: 'Sixth card description' },
      ]);
      setLoading(false);
    }, 500);
  }, []);
  
  if (loading) {
    return (
      <View style={styles.loading}>
        <Text>Loading...</Text>
      </View>
    );
  }
  
  return (
    <ScrollView style={styles.container} contentContainerStyle={styles.content}>
      {cards.map((card, index) => {
        const Animation = animations[index % animations.length];
        
        return (
          <Animated.View
            key={card.id}
            entering={Animation.delay(index * 100).springify()}
            style={styles.card}
          >
            <Text style={styles.cardTitle}>{card.title}</Text>
            <Text style={styles.cardDescription}>{card.description}</Text>
          </Animated.View>
        );
      })}
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  content: {
    padding: 16,
    gap: 12,
  },
  loading: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  card: {
    backgroundColor: 'white',
    padding: 20,
    borderRadius: 16,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  cardTitle: {
    fontSize: 18,
    fontWeight: '600',
    marginBottom: 8,
  },
  cardDescription: {
    fontSize: 14,
    color: '#666',
  },
});

Swipeable List Item

import React, { useState } from 'react';
import { StyleSheet, View, Text, FlatList, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  runOnJS,
  FadeIn,
  FadeOut,
  Layout,
} from 'react-native-reanimated';

const { width: SCREEN_WIDTH } = Dimensions.get('window');
const SWIPE_THRESHOLD = -80;

interface Task {
  id: string;
  title: string;
  completed: boolean;
}

function SwipeableTask({ 
  task, 
  onDelete,
  onToggle,
}: { 
  task: Task; 
  onDelete: (id: string) => void;
  onToggle: (id: string) => void;
}) {
  const translateX = useSharedValue(0);
  
  const panGesture = Gesture.Pan()
    .activeOffsetX([-10, 10])
    .onUpdate((event) => {
      translateX.value = Math.min(0, Math.max(-120, event.translationX));
    })
    .onEnd((event) => {
      if (translateX.value < SWIPE_THRESHOLD || event.velocityX < -500) {
        translateX.value = withTiming(-120);
      } else {
        translateX.value = withSpring(0);
      }
    });
  
  const handleDelete = () => {
    translateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
      runOnJS(onDelete)(task.id);
    });
  };
  
  const taskStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));
  
  return (
    <Animated.View
      entering={FadeIn.springify()}
      exiting={FadeOut.duration(200)}
      layout={Layout.springify()}
      style={styles.taskContainer}
    >
      {/* Delete button behind */}
      <View style={styles.deleteAction}>
        <GestureDetector gesture={Gesture.Tap().onEnd(handleDelete)}>
          <View style={styles.deleteButton}>
            <Text style={styles.deleteText}>🗑️</Text>
          </View>
        </GestureDetector>
      </View>
      
      {/* Swipeable task */}
      <GestureDetector gesture={panGesture}>
        <Animated.View style={[styles.task, taskStyle]}>
          <GestureDetector 
            gesture={Gesture.Tap().onEnd(() => onToggle(task.id))}
          >
            <View style={styles.checkbox}>
              {task.completed && <Text>✓</Text>}
            </View>
          </GestureDetector>
          <Text 
            style={[
              styles.taskTitle, 
              task.completed && styles.completedTask
            ]}
          >
            {task.title}
          </Text>
        </Animated.View>
      </GestureDetector>
    </Animated.View>
  );
}

function TaskList() {
  const [tasks, setTasks] = useState<Task[]>([
    { id: '1', title: 'Buy groceries', completed: false },
    { id: '2', title: 'Call mom', completed: true },
    { id: '3', title: 'Finish project', completed: false },
    { id: '4', title: 'Go to gym', completed: false },
  ]);
  
  const deleteTask = (id: string) => {
    setTasks(tasks.filter(t => t.id !== id));
  };
  
  const toggleTask = (id: string) => {
    setTasks(tasks.map(t => 
      t.id === id ? { ...t, completed: !t.completed } : t
    ));
  };
  
  return (
    <FlatList
      data={tasks}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <SwipeableTask 
          task={item} 
          onDelete={deleteTask}
          onToggle={toggleTask}
        />
      )}
      contentContainerStyle={styles.list}
    />
  );
}

const styles = StyleSheet.create({
  list: {
    padding: 16,
    gap: 8,
  },
  taskContainer: {
    position: 'relative',
    overflow: 'hidden',
    borderRadius: 12,
  },
  deleteAction: {
    position: 'absolute',
    right: 0,
    top: 0,
    bottom: 0,
    width: 120,
    backgroundColor: '#FF3B30',
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 12,
  },
  deleteButton: {
    padding: 20,
  },
  deleteText: {
    fontSize: 24,
  },
  task: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: 'white',
    padding: 16,
    borderRadius: 12,
  },
  checkbox: {
    width: 24,
    height: 24,
    borderWidth: 2,
    borderColor: '#007AFF',
    borderRadius: 6,
    marginRight: 12,
    justifyContent: 'center',
    alignItems: 'center',
  },
  taskTitle: {
    fontSize: 16,
    flex: 1,
  },
  completedTask: {
    textDecorationLine: 'line-through',
    color: '#999',
  },
});

Animated Section List

import React, { useState } from 'react';
import { StyleSheet, View, Text, SectionList, Pressable } from 'react-native';
import Animated, {
  FadeInRight,
  FadeOutLeft,
  Layout,
  SlideInDown,
} from 'react-native-reanimated';

interface Section {
  title: string;
  data: { id: string; name: string }[];
}

function AnimatedSectionList() {
  const [sections, setSections] = useState<Section[]>([
    {
      title: 'Favorites',
      data: [
        { id: '1', name: 'Item A' },
        { id: '2', name: 'Item B' },
      ],
    },
    {
      title: 'Recent',
      data: [
        { id: '3', name: 'Item C' },
        { id: '4', name: 'Item D' },
        { id: '5', name: 'Item E' },
      ],
    },
    {
      title: 'All',
      data: [
        { id: '6', name: 'Item F' },
        { id: '7', name: 'Item G' },
      ],
    },
  ]);
  
  const removeItem = (sectionTitle: string, itemId: string) => {
    setSections(sections.map(section => 
      section.title === sectionTitle
        ? { ...section, data: section.data.filter(item => item.id !== itemId) }
        : section
    ).filter(section => section.data.length > 0));
  };
  
  return (
    <SectionList
      sections={sections}
      keyExtractor={(item) => item.id}
      renderSectionHeader={({ section }) => (
        <Animated.View
          entering={SlideInDown.springify()}
          style={styles.sectionHeader}
        >
          <Text style={styles.sectionTitle}>{section.title}</Text>
        </Animated.View>
      )}
      renderItem={({ item, index, section }) => (
        <Animated.View
          entering={FadeInRight.delay(index * 50).springify()}
          exiting={FadeOutLeft.duration(200)}
          layout={Layout.springify()}
          style={styles.item}
        >
          <Text style={styles.itemName}>{item.name}</Text>
          <Pressable onPress={() => removeItem(section.title, item.id)}>
            <Text style={styles.removeButton}>×</Text>
          </Pressable>
        </Animated.View>
      )}
      contentContainerStyle={styles.container}
    />
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 16,
  },
  sectionHeader: {
    backgroundColor: '#f5f5f5',
    padding: 12,
    marginTop: 16,
    marginBottom: 8,
    borderRadius: 8,
  },
  sectionTitle: {
    fontSize: 14,
    fontWeight: '600',
    color: '#666',
    textTransform: 'uppercase',
  },
  item: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    backgroundColor: 'white',
    padding: 16,
    marginBottom: 8,
    borderRadius: 12,
  },
  itemName: {
    fontSize: 16,
  },
  removeButton: {
    fontSize: 24,
    color: '#999',
  },
});

Shared Element Transitions

Shared element transitions create visual continuity between screens by animating an element from one position/size to another. This is commonly used in photo galleries, product detail views, and card expansions.

Basic Shared Transition Concept

flowchart LR
    subgraph List["List Screen"]
        A["🖼️ Small thumbnail"]
        B[Title text]
    end
    
    subgraph Detail["Detail Screen"]
        C["🖼️ Large image"]
        D[Full content]
    end
    
    A -->|"Shared transition"| C
    B -.->|Fade| D
    
    style List fill:#e3f2fd
    style Detail fill:#e8f5e9

⚠️ Note on Shared Element Transitions

True shared element transitions (like React Navigation's shared element) require additional setup and libraries. Here we'll demonstrate the concept using Reanimated's layout animations to achieve similar effects within a single screen.

Expandable Card Pattern

import React, { useState } from 'react';
import { StyleSheet, View, Text, Image, Pressable, Dimensions } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  interpolate,
  FadeIn,
  FadeOut,
} from 'react-native-reanimated';

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

interface Card {
  id: string;
  title: string;
  image: string;
  description: string;
}

function ExpandableCard({ card }: { card: Card }) {
  const [expanded, setExpanded] = useState(false);
  const progress = useSharedValue(0);
  
  const toggle = () => {
    setExpanded(!expanded);
    progress.value = withSpring(expanded ? 0 : 1, {
      damping: 15,
      stiffness: 100,
    });
  };
  
  const containerStyle = useAnimatedStyle(() => {
    const width = interpolate(
      progress.value,
      [0, 1],
      [SCREEN_WIDTH - 32, SCREEN_WIDTH]
    );
    const height = interpolate(
      progress.value,
      [0, 1],
      [200, SCREEN_HEIGHT]
    );
    const borderRadius = interpolate(progress.value, [0, 1], [16, 0]);
    const translateX = interpolate(
      progress.value,
      [0, 1],
      [0, -16]
    );
    const translateY = interpolate(
      progress.value,
      [0, 1],
      [0, -100]
    );
    
    return {
      width,
      height,
      borderRadius,
      transform: [{ translateX }, { translateY }],
      zIndex: expanded ? 100 : 1,
    };
  });
  
  const imageStyle = useAnimatedStyle(() => {
    const height = interpolate(progress.value, [0, 1], [200, 300]);
    
    return { height };
  });
  
  const contentStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [
      { translateY: interpolate(progress.value, [0, 1], [20, 0]) },
    ],
  }));
  
  return (
    <Pressable onPress={toggle}>
      <Animated.View style={[styles.card, containerStyle]}>
        <Animated.Image
          source={{ uri: card.image }}
          style={[styles.cardImage, imageStyle]}
        />
        
        <View style={styles.cardContent}>
          <Text style={styles.cardTitle}>{card.title}</Text>
          
          {expanded && (
            <Animated.View
              entering={FadeIn.delay(200)}
              exiting={FadeOut.duration(100)}
            >
              <Animated.Text style={[styles.cardDescription, contentStyle]}>
                {card.description}
              </Animated.Text>
            </Animated.View>
          )}
        </View>
        
        {expanded && (
          <Animated.View
            entering={FadeIn.delay(300)}
            exiting={FadeOut.duration(100)}
            style={styles.closeButton}
          >
            <Text style={styles.closeText}>×</Text>
          </Animated.View>
        )}
      </Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  card: {
    backgroundColor: 'white',
    marginHorizontal: 16,
    marginVertical: 8,
    overflow: 'hidden',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.15,
    shadowRadius: 8,
    elevation: 5,
  },
  cardImage: {
    width: '100%',
  },
  cardContent: {
    padding: 16,
  },
  cardTitle: {
    fontSize: 20,
    fontWeight: '600',
    marginBottom: 8,
  },
  cardDescription: {
    fontSize: 16,
    lineHeight: 24,
    color: '#666',
  },
  closeButton: {
    position: 'absolute',
    top: 16,
    right: 16,
    width: 40,
    height: 40,
    backgroundColor: 'rgba(0,0,0,0.5)',
    borderRadius: 20,
    justifyContent: 'center',
    alignItems: 'center',
  },
  closeText: {
    color: 'white',
    fontSize: 24,
  },
});

Photo Gallery with Shared Transition

import React, { useState } from 'react';
import { 
  StyleSheet, 
  View, 
  Image, 
  Pressable, 
  Dimensions,
  Modal,
} from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  runOnJS,
  FadeIn,
  FadeOut,
} from 'react-native-reanimated';

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const COLUMNS = 3;
const GAP = 2;
const THUMB_SIZE = (SCREEN_WIDTH - (COLUMNS + 1) * GAP) / COLUMNS;

interface Photo {
  id: string;
  uri: string;
}

const photos: Photo[] = Array.from({ length: 12 }, (_, i) => ({
  id: `photo-${i}`,
  uri: `https://picsum.photos/400/400?random=${i}`,
}));

function PhotoGallery() {
  const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
  const [origin, setOrigin] = useState({ x: 0, y: 0 });
  
  const scale = useSharedValue(0);
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  
  const openPhoto = (photo: Photo, layout: { x: number; y: number }) => {
    setOrigin(layout);
    setSelectedPhoto(photo);
    
    // Calculate center offset
    translateX.value = layout.x - SCREEN_WIDTH / 2 + THUMB_SIZE / 2;
    translateY.value = layout.y - SCREEN_HEIGHT / 2 + THUMB_SIZE / 2;
    scale.value = THUMB_SIZE / SCREEN_WIDTH;
    
    // Animate to center
    translateX.value = withSpring(0, { damping: 15 });
    translateY.value = withSpring(0, { damping: 15 });
    scale.value = withSpring(1, { damping: 15 });
  };
  
  const closePhoto = () => {
    // Animate back to origin
    translateX.value = withSpring(
      origin.x - SCREEN_WIDTH / 2 + THUMB_SIZE / 2
    );
    translateY.value = withSpring(
      origin.y - SCREEN_HEIGHT / 2 + THUMB_SIZE / 2
    );
    scale.value = withTiming(THUMB_SIZE / SCREEN_WIDTH, { duration: 250 }, () => {
      runOnJS(setSelectedPhoto)(null);
    });
  };
  
  const imageStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
      { scale: scale.value },
    ],
  }));
  
  return (
    <View style={styles.container}>
      {/* Thumbnail Grid */}
      <View style={styles.grid}>
        {photos.map((photo, index) => {
          const row = Math.floor(index / COLUMNS);
          const col = index % COLUMNS;
          const x = col * (THUMB_SIZE + GAP) + GAP;
          const y = row * (THUMB_SIZE + GAP) + GAP;
          
          return (
            <Pressable
              key={photo.id}
              onPress={() => openPhoto(photo, { x, y })}
            >
              <Image
                source={{ uri: photo.uri }}
                style={styles.thumbnail}
              />
            </Pressable>
          );
        })}
      </View>
      
      {/* Full Screen Viewer */}
      {selectedPhoto && (
        <Pressable style={styles.overlay} onPress={closePhoto}>
          <Animated.View
            entering={FadeIn.duration(200)}
            exiting={FadeOut.duration(200)}
            style={styles.backdrop}
          />
          
          <Animated.Image
            source={{ uri: selectedPhoto.uri }}
            style={[styles.fullImage, imageStyle]}
            resizeMode="contain"
          />
        </Pressable>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  grid: {
    flexDirection: 'row',
    flexWrap: 'wrap',
  },
  thumbnail: {
    width: THUMB_SIZE,
    height: THUMB_SIZE,
    margin: GAP / 2,
  },
  overlay: {
    ...StyleSheet.absoluteFillObject,
    justifyContent: 'center',
    alignItems: 'center',
  },
  backdrop: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: 'black',
  },
  fullImage: {
    width: SCREEN_WIDTH,
    height: SCREEN_WIDTH,
  },
});

💡 For True Shared Element Transitions

For shared element transitions between navigation screens, consider:

  • React Navigation Shared Element: react-navigation-shared-element
  • Expo Router: Built-in shared transition support in newer versions
  • Reanimated 3: SharedTransition API for custom implementations

Hands-On Exercises

Exercise 1: Animated Notification Stack

Create a notification system where notifications stack, slide in, and can be dismissed.

Requirements:

  • Notifications slide in from the top
  • Multiple notifications stack with layout animation
  • Swipe right to dismiss individual notifications
  • Auto-dismiss after 5 seconds (optional)
Show Solution
import React, { useState, useEffect, useRef } from 'react';
import { StyleSheet, View, Text, Pressable, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  runOnJS,
  SlideInUp,
  SlideOutRight,
  Layout,
} from 'react-native-reanimated';

const { width: SCREEN_WIDTH } = Dimensions.get('window');

interface Notification {
  id: string;
  title: string;
  message: string;
  type: 'info' | 'success' | 'warning' | 'error';
}

function NotificationItem({ 
  notification, 
  onDismiss 
}: { 
  notification: Notification;
  onDismiss: (id: string) => void;
}) {
  const translateX = useSharedValue(0);
  const timerRef = useRef<NodeJS.Timeout>();
  
  useEffect(() => {
    // Auto-dismiss after 5 seconds
    timerRef.current = setTimeout(() => {
      onDismiss(notification.id);
    }, 5000);
    
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, []);
  
  const panGesture = Gesture.Pan()
    .onUpdate((event) => {
      // Only allow right swipe
      translateX.value = Math.max(0, event.translationX);
    })
    .onEnd((event) => {
      if (translateX.value > 100 || event.velocityX > 500) {
        translateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => {
          runOnJS(onDismiss)(notification.id);
        });
      } else {
        translateX.value = withSpring(0);
      }
    });
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
    opacity: 1 - translateX.value / SCREEN_WIDTH,
  }));
  
  const getColors = () => {
    switch (notification.type) {
      case 'success': return { bg: '#34C759', icon: '✓' };
      case 'error': return { bg: '#FF3B30', icon: '✕' };
      case 'warning': return { bg: '#FF9500', icon: '⚠' };
      default: return { bg: '#007AFF', icon: 'ℹ' };
    }
  };
  
  const colors = getColors();
  
  return (
    <GestureDetector gesture={panGesture}>
      <Animated.View
        entering={SlideInUp.springify().damping(15)}
        exiting={SlideOutRight.duration(200)}
        layout={Layout.springify()}
        style={[styles.notification, animatedStyle]}
      >
        <View style={[styles.iconContainer, { backgroundColor: colors.bg }]}>
          <Text style={styles.icon}>{colors.icon}</Text>
        </View>
        <View style={styles.content}>
          <Text style={styles.title}>{notification.title}</Text>
          <Text style={styles.message}>{notification.message}</Text>
        </View>
        <Pressable 
          style={styles.closeButton}
          onPress={() => onDismiss(notification.id)}
        >
          <Text style={styles.closeText}>×</Text>
        </Pressable>
      </Animated.View>
    </GestureDetector>
  );
}

function NotificationStack() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  
  const addNotification = (type: Notification['type']) => {
    const newNotification: Notification = {
      id: Date.now().toString(),
      title: `${type.charAt(0).toUpperCase() + type.slice(1)} Notification`,
      message: `This is a ${type} message that will auto-dismiss.`,
      type,
    };
    setNotifications(prev => [newNotification, ...prev]);
  };
  
  const dismissNotification = (id: string) => {
    setNotifications(prev => prev.filter(n => n.id !== id));
  };
  
  return (
    <View style={styles.container}>
      {/* Notification Stack */}
      <View style={styles.stack}>
        {notifications.map((notification) => (
          <NotificationItem
            key={notification.id}
            notification={notification}
            onDismiss={dismissNotification}
          />
        ))}
      </View>
      
      {/* Trigger Buttons */}
      <View style={styles.buttons}>
        <Pressable 
          style={[styles.button, { backgroundColor: '#007AFF' }]}
          onPress={() => addNotification('info')}
        >
          <Text style={styles.buttonText}>Info</Text>
        </Pressable>
        <Pressable 
          style={[styles.button, { backgroundColor: '#34C759' }]}
          onPress={() => addNotification('success')}
        >
          <Text style={styles.buttonText}>Success</Text>
        </Pressable>
        <Pressable 
          style={[styles.button, { backgroundColor: '#FF9500' }]}
          onPress={() => addNotification('warning')}
        >
          <Text style={styles.buttonText}>Warning</Text>
        </Pressable>
        <Pressable 
          style={[styles.button, { backgroundColor: '#FF3B30' }]}
          onPress={() => addNotification('error')}
        >
          <Text style={styles.buttonText}>Error</Text>
        </Pressable>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  stack: {
    position: 'absolute',
    top: 50,
    left: 16,
    right: 16,
    zIndex: 100,
    gap: 8,
  },
  notification: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: 'white',
    borderRadius: 12,
    padding: 12,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.15,
    shadowRadius: 8,
    elevation: 5,
  },
  iconContainer: {
    width: 36,
    height: 36,
    borderRadius: 18,
    justifyContent: 'center',
    alignItems: 'center',
    marginRight: 12,
  },
  icon: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
  content: {
    flex: 1,
  },
  title: {
    fontSize: 16,
    fontWeight: '600',
    marginBottom: 2,
  },
  message: {
    fontSize: 14,
    color: '#666',
  },
  closeButton: {
    padding: 8,
  },
  closeText: {
    fontSize: 20,
    color: '#999',
  },
  buttons: {
    position: 'absolute',
    bottom: 50,
    left: 16,
    right: 16,
    flexDirection: 'row',
    gap: 8,
  },
  button: {
    flex: 1,
    padding: 16,
    borderRadius: 12,
    alignItems: 'center',
  },
  buttonText: {
    color: 'white',
    fontWeight: '600',
  },
});

Exercise 2: Animated Tab Bar

Build a custom animated tab bar with smooth indicator transitions.

Requirements:

  • 4 tabs with icons
  • Animated indicator slides between tabs
  • Selected tab icon scales up
  • Smooth spring animations
Show Solution
import React, { useState } from 'react';
import { StyleSheet, View, Text, Pressable, Dimensions } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  interpolateColor,
} from 'react-native-reanimated';

const { width: SCREEN_WIDTH } = Dimensions.get('window');

interface Tab {
  key: string;
  icon: string;
  label: string;
}

const tabs: Tab[] = [
  { key: 'home', icon: '🏠', label: 'Home' },
  { key: 'search', icon: '🔍', label: 'Search' },
  { key: 'notifications', icon: '🔔', label: 'Alerts' },
  { key: 'profile', icon: '👤', label: 'Profile' },
];

const TAB_WIDTH = SCREEN_WIDTH / tabs.length;

function AnimatedTabBar() {
  const [activeIndex, setActiveIndex] = useState(0);
  const indicatorPosition = useSharedValue(0);
  
  const handleTabPress = (index: number) => {
    setActiveIndex(index);
    indicatorPosition.value = withSpring(index * TAB_WIDTH, {
      damping: 15,
      stiffness: 120,
    });
  };
  
  const indicatorStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: indicatorPosition.value }],
  }));
  
  return (
    <View style={styles.container}>
      {/* Content Area */}
      <View style={styles.content}>
        <Text style={styles.contentText}>
          {tabs[activeIndex].label} Screen
        </Text>
      </View>
      
      {/* Tab Bar */}
      <View style={styles.tabBar}>
        {/* Animated Indicator */}
        <Animated.View style={[styles.indicator, indicatorStyle]} />
        
        {/* Tabs */}
        {tabs.map((tab, index) => (
          <TabItem
            key={tab.key}
            tab={tab}
            index={index}
            activeIndex={activeIndex}
            onPress={() => handleTabPress(index)}
          />
        ))}
      </View>
    </View>
  );
}

function TabItem({
  tab,
  index,
  activeIndex,
  onPress,
}: {
  tab: Tab;
  index: number;
  activeIndex: number;
  onPress: () => void;
}) {
  const isActive = index === activeIndex;
  const scale = useSharedValue(1);
  
  React.useEffect(() => {
    scale.value = withSpring(isActive ? 1.2 : 1, {
      damping: 12,
      stiffness: 150,
    });
  }, [isActive]);
  
  const iconStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  
  const labelStyle = useAnimatedStyle(() => ({
    opacity: isActive ? 1 : 0.6,
    transform: [{ scale: isActive ? 1 : 0.9 }],
  }));
  
  return (
    <Pressable style={styles.tab} onPress={onPress}>
      <Animated.Text style={[styles.tabIcon, iconStyle]}>
        {tab.icon}
      </Animated.Text>
      <Animated.Text 
        style={[
          styles.tabLabel, 
          labelStyle,
          isActive && styles.activeLabel
        ]}
      >
        {tab.label}
      </Animated.Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  content: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  contentText: {
    fontSize: 24,
    fontWeight: '600',
  },
  tabBar: {
    flexDirection: 'row',
    height: 80,
    backgroundColor: 'white',
    borderTopWidth: 1,
    borderTopColor: '#eee',
    paddingBottom: 20,
  },
  indicator: {
    position: 'absolute',
    top: 0,
    width: TAB_WIDTH,
    height: 3,
    backgroundColor: '#007AFF',
  },
  tab: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    paddingTop: 8,
  },
  tabIcon: {
    fontSize: 24,
    marginBottom: 4,
  },
  tabLabel: {
    fontSize: 12,
    color: '#666',
  },
  activeLabel: {
    color: '#007AFF',
    fontWeight: '600',
  },
});

Exercise 3: Animated Form Validation

Create a form with animated validation feedback.

Requirements:

  • Input fields with animated border color on focus
  • Shake animation on validation error
  • Success checkmark animation when valid
  • Error message slides in with layout animation
Show Solution
import React, { useState, useRef } from 'react';
import { StyleSheet, View, Text, TextInput, Pressable } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  withSequence,
  interpolateColor,
  FadeIn,
  FadeOut,
  Layout,
} from 'react-native-reanimated';

const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);

type ValidationState = 'idle' | 'valid' | 'invalid';

interface FormFieldProps {
  label: string;
  value: string;
  onChangeText: (text: string) => void;
  placeholder?: string;
  validate: (value: string) => string | null;
  secureTextEntry?: boolean;
}

function FormField({
  label,
  value,
  onChangeText,
  placeholder,
  validate,
  secureTextEntry,
}: FormFieldProps) {
  const [focused, setFocused] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [validationState, setValidationState] = useState<ValidationState>('idle');
  
  const borderProgress = useSharedValue(0);
  const shakeX = useSharedValue(0);
  const checkScale = useSharedValue(0);
  
  const handleFocus = () => {
    setFocused(true);
    borderProgress.value = withTiming(0.5);
  };
  
  const handleBlur = () => {
    setFocused(false);
    
    const validationError = validate(value);
    
    if (validationError) {
      setError(validationError);
      setValidationState('invalid');
      borderProgress.value = withTiming(1);
      checkScale.value = withTiming(0);
      
      // Shake animation
      shakeX.value = withSequence(
        withTiming(-10, { duration: 50 }),
        withTiming(10, { duration: 50 }),
        withTiming(-10, { duration: 50 }),
        withTiming(10, { duration: 50 }),
        withTiming(0, { duration: 50 })
      );
    } else {
      setError(null);
      setValidationState('valid');
      borderProgress.value = withTiming(0);
      checkScale.value = withSpring(1, { damping: 8 });
    }
  };
  
  const containerStyle = useAnimatedStyle(() => {
    const borderColor = interpolateColor(
      borderProgress.value,
      [0, 0.5, 1],
      ['#E0E0E0', '#007AFF', '#FF3B30']
    );
    
    return {
      borderColor,
      transform: [{ translateX: shakeX.value }],
    };
  });
  
  const checkStyle = useAnimatedStyle(() => ({
    transform: [{ scale: checkScale.value }],
    opacity: checkScale.value,
  }));
  
  return (
    <Animated.View layout={Layout.springify()} style={styles.fieldContainer}>
      <Text style={styles.label}>{label}</Text>
      
      <Animated.View style={[styles.inputContainer, containerStyle]}>
        <TextInput
          style={styles.input}
          value={value}
          onChangeText={onChangeText}
          onFocus={handleFocus}
          onBlur={handleBlur}
          placeholder={placeholder}
          placeholderTextColor="#999"
          secureTextEntry={secureTextEntry}
        />
        
        {validationState === 'valid' && (
          <Animated.View style={[styles.checkmark, checkStyle]}>
            <Text style={styles.checkmarkText}>✓</Text>
          </Animated.View>
        )}
      </Animated.View>
      
      {error && (
        <Animated.Text
          entering={FadeIn.duration(200)}
          exiting={FadeOut.duration(200)}
          style={styles.errorText}
        >
          {error}
        </Animated.Text>
      )}
    </Animated.View>
  );
}

function AnimatedForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  
  const validateEmail = (value: string) => {
    if (!value) return 'Email is required';
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      return 'Please enter a valid email';
    }
    return null;
  };
  
  const validatePassword = (value: string) => {
    if (!value) return 'Password is required';
    if (value.length < 8) return 'Password must be at least 8 characters';
    return null;
  };
  
  const validateConfirmPassword = (value: string) => {
    if (!value) return 'Please confirm your password';
    if (value !== password) return 'Passwords do not match';
    return null;
  };
  
  return (
    <View style={styles.form}>
      <Text style={styles.title}>Create Account</Text>
      
      <FormField
        label="Email"
        value={email}
        onChangeText={setEmail}
        placeholder="you@example.com"
        validate={validateEmail}
      />
      
      <FormField
        label="Password"
        value={password}
        onChangeText={setPassword}
        placeholder="At least 8 characters"
        validate={validatePassword}
        secureTextEntry
      />
      
      <FormField
        label="Confirm Password"
        value={confirmPassword}
        onChangeText={setConfirmPassword}
        placeholder="Re-enter your password"
        validate={validateConfirmPassword}
        secureTextEntry
      />
      
      <Pressable style={styles.submitButton}>
        <Text style={styles.submitText}>Sign Up</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  form: {
    padding: 20,
  },
  title: {
    fontSize: 28,
    fontWeight: 'bold',
    marginBottom: 24,
    textAlign: 'center',
  },
  fieldContainer: {
    marginBottom: 20,
  },
  label: {
    fontSize: 14,
    fontWeight: '600',
    marginBottom: 8,
    color: '#333',
  },
  inputContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    borderWidth: 2,
    borderRadius: 12,
    backgroundColor: 'white',
  },
  input: {
    flex: 1,
    padding: 16,
    fontSize: 16,
  },
  checkmark: {
    width: 24,
    height: 24,
    borderRadius: 12,
    backgroundColor: '#34C759',
    justifyContent: 'center',
    alignItems: 'center',
    marginRight: 12,
  },
  checkmarkText: {
    color: 'white',
    fontWeight: 'bold',
  },
  errorText: {
    color: '#FF3B30',
    fontSize: 12,
    marginTop: 6,
    marginLeft: 4,
  },
  submitButton: {
    backgroundColor: '#007AFF',
    padding: 18,
    borderRadius: 12,
    alignItems: 'center',
    marginTop: 20,
  },
  submitText: {
    color: 'white',
    fontSize: 18,
    fontWeight: '600',
  },
});

Summary

Layout animations bring polish and professionalism to your React Native apps by smoothly handling component lifecycle events. With Reanimated's declarative API, you can easily add entering, exiting, and layout transitions that would otherwise require complex manual animation code.

🎯 Key Takeaways

  • Entering animations: Run when components mount (FadeIn, SlideInUp, ZoomIn, etc.)
  • Exiting animations: Run when components unmount, delaying removal (FadeOut, SlideOutLeft, etc.)
  • Layout transitions: Animate position/size changes within layouts (Layout.springify())
  • Keyframes: Define multi-step animations with precise timing control
  • Customization: Chain modifiers like .delay(), .duration(), .springify()
  • Lists: Combine with FlatList for animated add/remove/reorder

Layout Animation Quick Reference

Animation Type Usage Common Options
entering <Animated.View entering={FadeIn}> .delay(), .duration(), .springify()
exiting <Animated.View exiting={FadeOut}> .delay(), .duration()
layout <Animated.View layout={Layout}> .springify(), .damping(), .stiffness()
Keyframe new Keyframe({ 0: {...}, 100: {...} }) .duration(), .delay()

Animation Categories

Category Entering Exiting
Fade FadeIn, FadeInUp, FadeInDown FadeOut, FadeOutUp, FadeOutDown
Slide SlideInLeft, SlideInRight, SlideInUp SlideOutLeft, SlideOutRight, SlideOutUp
Zoom ZoomIn, ZoomInRotate ZoomOut, ZoomOutRotate
Bounce BounceIn, BounceInUp BounceOut, BounceOutUp
Flip FlipInXUp, FlipInYLeft FlipOutXUp, FlipOutYLeft

This concludes Module 9 on Animations and Gestures. You now have the skills to create smooth, performant, and engaging animated interfaces that feel native and responsive.