Skip to main content

Module 9: Animations and Gestures

Common Animation Patterns

Build reusable animation components for fades, slides, bounces, and more

🎯 Learning Objectives

  • Implement fade in/out animations for content transitions
  • Create slide animations from different directions
  • Build bounce and scale effects for interactive feedback
  • Design staggered list animations for dynamic content
  • Create skeleton loading animations
  • Build reusable animated wrapper components

Fade Animations

Fades are the most common animation patternβ€”they provide smooth transitions for appearing and disappearing content without jarring visual changes.

Basic Fade In Component

import React, { useEffect } from 'react';
import { ViewProps } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  withDelay,
} from 'react-native-reanimated';

interface FadeInProps extends ViewProps {
  delay?: number;
  duration?: number;
  children: React.ReactNode;
}

export function FadeIn({ 
  delay = 0, 
  duration = 400, 
  style, 
  children,
  ...props 
}: FadeInProps) {
  const opacity = useSharedValue(0);
  
  useEffect(() => {
    opacity.value = withDelay(
      delay,
      withTiming(1, { duration })
    );
  }, [delay, duration]);
  
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
  }));
  
  return (
    <Animated.View style={[animatedStyle, style]} {...props}>
      {children}
    </Animated.View>
  );
}

// Usage
function MyScreen() {
  return (
    <View>
      <FadeIn>
        <Text>This fades in immediately</Text>
      </FadeIn>
      
      <FadeIn delay={200}>
        <Text>This fades in after 200ms</Text>
      </FadeIn>
      
      <FadeIn delay={400} duration={800}>
        <Text>This fades in slowly after 400ms</Text>
      </FadeIn>
    </View>
  );
}

Controllable Fade Component

import React, { useEffect } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
} from 'react-native-reanimated';

interface FadeProps {
  visible: boolean;
  duration?: number;
  children: React.ReactNode;
  style?: any;
}

export function Fade({ 
  visible, 
  duration = 300, 
  children, 
  style 
}: FadeProps) {
  const opacity = useSharedValue(visible ? 1 : 0);
  
  useEffect(() => {
    opacity.value = withTiming(visible ? 1 : 0, { duration });
  }, [visible, duration]);
  
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
  }));
  
  return (
    <Animated.View style={[animatedStyle, style]}>
      {children}
    </Animated.View>
  );
}

// Usage
function ToggleContent() {
  const [isVisible, setIsVisible] = useState(true);
  
  return (
    <View>
      <Pressable onPress={() => setIsVisible(!isVisible)}>
        <Text>Toggle Content</Text>
      </Pressable>
      
      <Fade visible={isVisible}>
        <View style={styles.content}>
          <Text>This content fades in and out</Text>
        </View>
      </Fade>
    </View>
  );
}

Fade with Unmount

import React, { useState, useEffect } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  runOnJS,
} from 'react-native-reanimated';

interface FadeUnmountProps {
  visible: boolean;
  duration?: number;
  children: React.ReactNode;
  style?: any;
}

export function FadeUnmount({ 
  visible, 
  duration = 300, 
  children, 
  style 
}: FadeUnmountProps) {
  const [shouldRender, setShouldRender] = useState(visible);
  const opacity = useSharedValue(visible ? 1 : 0);
  
  useEffect(() => {
    if (visible) {
      setShouldRender(true);
      opacity.value = withTiming(1, { duration });
    } else {
      opacity.value = withTiming(0, { duration }, (finished) => {
        if (finished) {
          runOnJS(setShouldRender)(false);
        }
      });
    }
  }, [visible, duration]);
  
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
  }));
  
  if (!shouldRender) return null;
  
  return (
    <Animated.View style={[animatedStyle, style]}>
      {children}
    </Animated.View>
  );
}

// Usage - component unmounts after fade out
function Modal({ isOpen, onClose, children }) {
  return (
    <FadeUnmount visible={isOpen}>
      <View style={styles.modalBackdrop}>
        <View style={styles.modalContent}>
          {children}
          <Pressable onPress={onClose}>
            <Text>Close</Text>
          </Pressable>
        </View>
      </View>
    </FadeUnmount>
  );
}

Cross-Fade Transition

import React, { useState, useEffect } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
} from 'react-native-reanimated';

interface CrossFadeProps {
  activeIndex: number;
  children: React.ReactNode[];
  duration?: number;
}

export function CrossFade({ 
  activeIndex, 
  children, 
  duration = 300 
}: CrossFadeProps) {
  return (
    <View style={{ position: 'relative' }}>
      {React.Children.map(children, (child, index) => (
        <CrossFadeItem 
          key={index}
          isActive={index === activeIndex}
          duration={duration}
          isFirst={index === 0}
        >
          {child}
        </CrossFadeItem>
      ))}
    </View>
  );
}

function CrossFadeItem({ 
  isActive, 
  duration, 
  isFirst, 
  children 
}: {
  isActive: boolean;
  duration: number;
  isFirst: boolean;
  children: React.ReactNode;
}) {
  const opacity = useSharedValue(isActive ? 1 : 0);
  
  useEffect(() => {
    opacity.value = withTiming(isActive ? 1 : 0, { duration });
  }, [isActive, duration]);
  
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
    position: isFirst ? 'relative' : 'absolute',
    top: 0,
    left: 0,
    right: 0,
  }));
  
  return (
    <Animated.View style={animatedStyle}>
      {children}
    </Animated.View>
  );
}

// Usage - smooth transition between views
function ImageCarousel() {
  const [currentIndex, setCurrentIndex] = useState(0);
  const images = ['image1.jpg', 'image2.jpg', 'image3.jpg'];
  
  return (
    <View>
      <CrossFade activeIndex={currentIndex}>
        {images.map((img, index) => (
          <Image key={index} source={{ uri: img }} style={styles.image} />
        ))}
      </CrossFade>
      
      <View style={styles.dots}>
        {images.map((_, index) => (
          <Pressable 
            key={index}
            onPress={() => setCurrentIndex(index)}
          >
            <View style={[
              styles.dot,
              index === currentIndex && styles.activeDot
            ]} />
          </Pressable>
        ))}
      </View>
    </View>
  );
}

Slide Animations

Slide animations create a sense of spatial movement and are perfect for screen transitions, drawers, and content reveals.

Slide In Component

import React, { useEffect } from 'react';
import { useWindowDimensions } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withDelay,
} from 'react-native-reanimated';

type SlideDirection = 'left' | 'right' | 'up' | 'down';

interface SlideInProps {
  direction?: SlideDirection;
  delay?: number;
  distance?: number;
  children: React.ReactNode;
  style?: any;
}

export function SlideIn({
  direction = 'up',
  delay = 0,
  distance,
  children,
  style,
}: SlideInProps) {
  const { width, height } = useWindowDimensions();
  const translate = useSharedValue(0);
  
  // Calculate initial offset based on direction
  const getInitialOffset = () => {
    const defaultDistance = distance ?? (
      direction === 'left' || direction === 'right' ? width : height * 0.3
    );
    
    switch (direction) {
      case 'left': return -defaultDistance;
      case 'right': return defaultDistance;
      case 'up': return defaultDistance;
      case 'down': return -defaultDistance;
    }
  };
  
  useEffect(() => {
    translate.value = getInitialOffset();
    translate.value = withDelay(
      delay,
      withSpring(0, { damping: 15, stiffness: 100 })
    );
  }, [direction, delay, distance]);
  
  const animatedStyle = useAnimatedStyle(() => {
    const isHorizontal = direction === 'left' || direction === 'right';
    
    return {
      transform: [
        isHorizontal 
          ? { translateX: translate.value }
          : { translateY: translate.value }
      ],
    };
  });
  
  return (
    <Animated.View style={[animatedStyle, style]}>
      {children}
    </Animated.View>
  );
}

// Usage
function WelcomeScreen() {
  return (
    <View style={styles.container}>
      <SlideIn direction="down" delay={0}>
        <Text style={styles.title}>Welcome</Text>
      </SlideIn>
      
      <SlideIn direction="left" delay={200}>
        <Text style={styles.subtitle}>Let's get started</Text>
      </SlideIn>
      
      <SlideIn direction="up" delay={400}>
        <Pressable style={styles.button}>
          <Text>Continue</Text>
        </Pressable>
      </SlideIn>
    </View>
  );
}

Slide and Fade Combo

import React, { useEffect } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  withDelay,
  Easing,
} from 'react-native-reanimated';

interface SlideAndFadeProps {
  visible: boolean;
  direction?: 'up' | 'down' | 'left' | 'right';
  distance?: number;
  duration?: number;
  children: React.ReactNode;
  style?: any;
}

export function SlideAndFade({
  visible,
  direction = 'up',
  distance = 20,
  duration = 300,
  children,
  style,
}: SlideAndFadeProps) {
  const opacity = useSharedValue(visible ? 1 : 0);
  const translate = useSharedValue(visible ? 0 : distance);
  
  useEffect(() => {
    const config = { 
      duration, 
      easing: Easing.out(Easing.ease) 
    };
    
    if (visible) {
      opacity.value = withTiming(1, config);
      translate.value = withTiming(0, config);
    } else {
      opacity.value = withTiming(0, config);
      translate.value = withTiming(distance, config);
    }
  }, [visible, distance, duration]);
  
  const animatedStyle = useAnimatedStyle(() => {
    const translateProp = 
      direction === 'left' || direction === 'right' 
        ? 'translateX' 
        : 'translateY';
    
    const sign = direction === 'down' || direction === 'right' ? -1 : 1;
    
    return {
      opacity: opacity.value,
      transform: [{ [translateProp]: translate.value * sign }],
    };
  });
  
  return (
    <Animated.View style={[animatedStyle, style]}>
      {children}
    </Animated.View>
  );
}

// Usage
function NotificationBanner({ message, visible }) {
  return (
    <SlideAndFade visible={visible} direction="down">
      <View style={styles.banner}>
        <Text style={styles.bannerText}>{message}</Text>
      </View>
    </SlideAndFade>
  );
}

Drawer Animation

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

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

interface DrawerProps {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
  drawerContent: React.ReactNode;
}

export function Drawer({ 
  isOpen, 
  onClose, 
  children, 
  drawerContent 
}: DrawerProps) {
  const progress = useSharedValue(0);
  
  React.useEffect(() => {
    progress.value = withSpring(isOpen ? 1 : 0, {
      damping: 20,
      stiffness: 100,
    });
  }, [isOpen]);
  
  const drawerStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: interpolate(
        progress.value,
        [0, 1],
        [-DRAWER_WIDTH, 0]
      )},
    ],
  }));
  
  const contentStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: interpolate(
        progress.value,
        [0, 1],
        [0, DRAWER_WIDTH * 0.3]
      )},
      { scale: interpolate(
        progress.value,
        [0, 1],
        [1, 0.95]
      )},
    ],
    borderRadius: interpolate(progress.value, [0, 1], [0, 20]),
    overflow: 'hidden',
  }));
  
  const backdropStyle = useAnimatedStyle(() => ({
    opacity: progress.value * 0.5,
    pointerEvents: isOpen ? 'auto' : 'none',
  }));
  
  return (
    <View style={styles.container}>
      {/* Main Content */}
      <Animated.View style={[styles.content, contentStyle]}>
        {children}
      </Animated.View>
      
      {/* Backdrop */}
      <Animated.View style={[styles.backdrop, backdropStyle]}>
        <Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
      </Animated.View>
      
      {/* Drawer */}
      <Animated.View style={[styles.drawer, drawerStyle]}>
        {drawerContent}
      </Animated.View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  content: {
    flex: 1,
    backgroundColor: '#fff',
  },
  backdrop: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: '#000',
  },
  drawer: {
    position: 'absolute',
    left: 0,
    top: 0,
    bottom: 0,
    width: DRAWER_WIDTH,
    backgroundColor: '#fff',
    shadowColor: '#000',
    shadowOffset: { width: 2, height: 0 },
    shadowOpacity: 0.25,
    shadowRadius: 10,
    elevation: 5,
  },
});

Scale and Bounce Effects

Scale animations provide immediate tactile feedback and help users understand what's interactive. Bounce effects add personality and delight to your UI.

Press Scale Effect

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

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

interface ScalePressableProps extends PressableProps {
  scaleValue?: number;
  children: React.ReactNode;
}

export function ScalePressable({ 
  scaleValue = 0.95, 
  style, 
  children, 
  ...props 
}: ScalePressableProps) {
  const scale = useSharedValue(1);
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  
  return (
    <AnimatedPressable
      onPressIn={() => {
        scale.value = withSpring(scaleValue, {
          damping: 15,
          stiffness: 400,
        });
      }}
      onPressOut={() => {
        scale.value = withSpring(1, {
          damping: 15,
          stiffness: 400,
        });
      }}
      style={[animatedStyle, style]}
      {...props}
    >
      {children}
    </AnimatedPressable>
  );
}

// Usage
function ButtonExample() {
  return (
    <ScalePressable 
      style={styles.button}
      onPress={() => console.log('Pressed!')}
    >
      <Text style={styles.buttonText}>Press Me</Text>
    </ScalePressable>
  );
}

Bounce Animation

import React, { useEffect } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withSequence,
  withDelay,
} from 'react-native-reanimated';

interface BounceInProps {
  delay?: number;
  children: React.ReactNode;
  style?: any;
}

export function BounceIn({ delay = 0, children, style }: BounceInProps) {
  const scale = useSharedValue(0);
  
  useEffect(() => {
    scale.value = withDelay(
      delay,
      withSpring(1, {
        damping: 8,
        stiffness: 100,
      })
    );
  }, [delay]);
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  
  return (
    <Animated.View style={[animatedStyle, style]}>
      {children}
    </Animated.View>
  );
}

// Pop animation (scale up then settle)
export function PopIn({ delay = 0, children, style }) {
  const scale = useSharedValue(0);
  
  useEffect(() => {
    scale.value = withDelay(
      delay,
      withSequence(
        withSpring(1.2, { damping: 10, stiffness: 200 }),
        withSpring(1, { damping: 15, stiffness: 150 })
      )
    );
  }, [delay]);
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  
  return (
    <Animated.View style={[animatedStyle, style]}>
      {children}
    </Animated.View>
  );
}

// Usage
function BadgeNotification() {
  const [count, setCount] = useState(0);
  
  return (
    <View>
      <Pressable onPress={() => setCount(c => c + 1)}>
        <View style={styles.icon}>
          <Text>πŸ””</Text>
        </View>
        
        {count > 0 && (
          <PopIn key={count}>
            <View style={styles.badge}>
              <Text style={styles.badgeText}>{count}</Text>
            </View>
          </PopIn>
        )}
      </Pressable>
    </View>
  );
}

Shake Animation

import React, { useImperativeHandle, forwardRef } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSequence,
  withTiming,
} from 'react-native-reanimated';

export interface ShakeRef {
  shake: () => void;
}

interface ShakeProps {
  children: React.ReactNode;
  style?: any;
}

export const Shake = forwardRef<ShakeRef, ShakeProps>(
  ({ children, style }, ref) => {
    const translateX = useSharedValue(0);
    
    useImperativeHandle(ref, () => ({
      shake: () => {
        translateX.value = withSequence(
          withTiming(-10, { duration: 50 }),
          withTiming(10, { duration: 50 }),
          withTiming(-10, { duration: 50 }),
          withTiming(10, { duration: 50 }),
          withTiming(-5, { duration: 50 }),
          withTiming(5, { duration: 50 }),
          withTiming(0, { duration: 50 })
        );
      },
    }));
    
    const animatedStyle = useAnimatedStyle(() => ({
      transform: [{ translateX: translateX.value }],
    }));
    
    return (
      <Animated.View style={[animatedStyle, style]}>
        {children}
      </Animated.View>
    );
  }
);

// Usage - shake on error
function LoginForm() {
  const shakeRef = useRef<ShakeRef>(null);
  const [error, setError] = useState(false);
  
  const handleSubmit = () => {
    if (/* validation fails */) {
      setError(true);
      shakeRef.current?.shake();
    }
  };
  
  return (
    <View>
      <Shake ref={shakeRef}>
        <TextInput
          style={[styles.input, error && styles.inputError]}
          placeholder="Password"
        />
      </Shake>
      
      <Pressable onPress={handleSubmit}>
        <Text>Login</Text>
      </Pressable>
    </View>
  );
}

Pulse Animation

import React, { useEffect } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withRepeat,
  withSequence,
  withTiming,
  Easing,
} from 'react-native-reanimated';

interface PulseProps {
  duration?: number;
  minScale?: number;
  maxScale?: number;
  children: React.ReactNode;
  style?: any;
}

export function Pulse({
  duration = 1000,
  minScale = 1,
  maxScale = 1.1,
  children,
  style,
}: PulseProps) {
  const scale = useSharedValue(minScale);
  
  useEffect(() => {
    scale.value = withRepeat(
      withSequence(
        withTiming(maxScale, { 
          duration: duration / 2,
          easing: Easing.inOut(Easing.ease),
        }),
        withTiming(minScale, { 
          duration: duration / 2,
          easing: Easing.inOut(Easing.ease),
        })
      ),
      -1, // Infinite repeat
      false // Don't reverse
    );
  }, [duration, minScale, maxScale]);
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  
  return (
    <Animated.View style={[animatedStyle, style]}>
      {children}
    </Animated.View>
  );
}

// Usage - attention-grabbing button
function RecordButton() {
  const [isRecording, setIsRecording] = useState(false);
  
  return (
    <Pressable onPress={() => setIsRecording(!isRecording)}>
      {isRecording ? (
        <Pulse minScale={0.9} maxScale={1.1} duration={800}>
          <View style={[styles.recordButton, styles.recording]}>
            <View style={styles.recordDot} />
          </View>
        </Pulse>
      ) : (
        <View style={styles.recordButton}>
          <View style={styles.recordDot} />
        </View>
      )}
    </Pressable>
  );
}

Staggered List Animations

Staggered animations bring lists to life by animating items in sequence. This creates a cascading effect that guides the user's attention through content.

Basic Staggered List

import React, { useEffect } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withDelay,
  withSpring,
  FadeIn,
  FadeOut,
  Layout,
} from 'react-native-reanimated';

interface StaggeredItemProps {
  index: number;
  children: React.ReactNode;
}

function StaggeredItem({ index, children }: StaggeredItemProps) {
  const opacity = useSharedValue(0);
  const translateY = useSharedValue(20);
  
  useEffect(() => {
    const delay = index * 100;
    
    opacity.value = withDelay(delay, withSpring(1));
    translateY.value = withDelay(
      delay, 
      withSpring(0, { damping: 15, stiffness: 100 })
    );
  }, [index]);
  
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
    transform: [{ translateY: translateY.value }],
  }));
  
  return (
    <Animated.View style={animatedStyle}>
      {children}
    </Animated.View>
  );
}

// Usage with FlatList
function StaggeredFlatList({ data }) {
  const renderItem = ({ item, index }) => (
    <StaggeredItem index={index}>
      <View style={styles.listItem}>
        <Text style={styles.itemTitle}>{item.title}</Text>
        <Text style={styles.itemSubtitle}>{item.subtitle}</Text>
      </View>
    </StaggeredItem>
  );
  
  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={(item) => item.id}
      contentContainerStyle={styles.list}
    />
  );
}

Reanimated Layout Animations

Reanimated provides built-in entering and exiting animations that work seamlessly with lists.

import React from 'react';
import { FlatList, Pressable, Text, View } from 'react-native';
import Animated, {
  FadeIn,
  FadeOut,
  SlideInRight,
  SlideOutLeft,
  Layout,
  ZoomIn,
  ZoomOut,
} from 'react-native-reanimated';

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

function AnimatedList() {
  const [items, setItems] = useState<Item[]>([
    { 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 = ({ item, index }: { item: Item; index: number }) => (
    <Animated.View
      entering={SlideInRight.delay(index * 100).springify()}
      exiting={SlideOutLeft.duration(300)}
      layout={Layout.springify()}
      style={styles.item}
    >
      <Text style={styles.itemText}>{item.title}</Text>
      <Pressable 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.addButtonText}>Add Item</Text>
      </Pressable>
      
      <FlatList
        data={items}
        renderItem={renderItem}
        keyExtractor={(item) => item.id}
      />
    </View>
  );
}

// Built-in entering animations:
// - FadeIn, FadeInUp, FadeInDown, FadeInLeft, FadeInRight
// - SlideInUp, SlideInDown, SlideInLeft, SlideInRight
// - ZoomIn, ZoomInUp, ZoomInDown, ZoomInLeft, ZoomInRight
// - BounceIn, BounceInUp, BounceInDown, BounceInLeft, BounceInRight
// - FlipInXUp, FlipInXDown, FlipInYLeft, FlipInYRight
// - StretchInX, StretchInY
// - LightSpeedInLeft, LightSpeedInRight

// Built-in exiting animations:
// - FadeOut, FadeOutUp, FadeOutDown, FadeOutLeft, FadeOutRight
// - SlideOutUp, SlideOutDown, SlideOutLeft, SlideOutRight
// - ZoomOut, ZoomOutUp, ZoomOutDown, ZoomOutLeft, ZoomOutRight
// - BounceOut, BounceOutUp, BounceOutDown, BounceOutLeft, BounceOutRight

Custom Entering/Exiting Animations

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

// Custom entering animation
const CustomEntering = (targetValues) => {
  'worklet';
  const animations = {
    opacity: withTiming(1, { duration: 300 }),
    transform: [
      { translateY: withSpring(0, { damping: 15 }) },
      { scale: withSpring(1, { damping: 12 }) },
      { rotate: withTiming('0deg', { duration: 300 }) },
    ],
  };
  
  const initialValues = {
    opacity: 0,
    transform: [
      { translateY: 50 },
      { scale: 0.8 },
      { rotate: '-10deg' },
    ],
  };
  
  return {
    initialValues,
    animations,
  };
};

// Custom exiting animation
const CustomExiting = (values) => {
  'worklet';
  const animations = {
    opacity: withTiming(0, { duration: 200 }),
    transform: [
      { translateX: withTiming(-100, { duration: 200 }) },
      { scale: withTiming(0.5, { duration: 200 }) },
    ],
  };
  
  const initialValues = {
    opacity: 1,
    transform: [
      { translateX: 0 },
      { scale: 1 },
    ],
  };
  
  return {
    initialValues,
    animations,
  };
};

// Usage
function CustomAnimatedItem({ item }) {
  return (
    <Animated.View
      entering={CustomEntering}
      exiting={CustomExiting}
      style={styles.item}
    >
      <Text>{item.title}</Text>
    </Animated.View>
  );
}

Staggered Grid Animation

import React, { useEffect, useState } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withDelay,
  withSpring,
} from 'react-native-reanimated';

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

interface GridItem {
  id: string;
  color: string;
}

function StaggeredGridItem({ 
  item, 
  index 
}: { 
  item: GridItem; 
  index: number;
}) {
  const scale = useSharedValue(0);
  const opacity = useSharedValue(0);
  
  // Calculate row and column for stagger delay
  const row = Math.floor(index / COLUMNS);
  const col = index % COLUMNS;
  const delay = (row + col) * 50; // Diagonal stagger
  
  useEffect(() => {
    scale.value = withDelay(delay, withSpring(1, { damping: 12 }));
    opacity.value = withDelay(delay, withSpring(1));
  }, []);
  
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
    transform: [{ scale: scale.value }],
  }));
  
  return (
    <Animated.View
      style={[
        styles.gridItem,
        { backgroundColor: item.color },
        animatedStyle,
      ]}
    />
  );
}

function StaggeredGrid() {
  const items: GridItem[] = Array.from({ length: 12 }, (_, i) => ({
    id: `item-${i}`,
    color: `hsl(${i * 30}, 70%, 60%)`,
  }));
  
  return (
    <View style={styles.grid}>
      {items.map((item, index) => (
        <StaggeredGridItem key={item.id} item={item} index={index} />
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  grid: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    padding: 20,
    gap: 10,
  },
  gridItem: {
    width: ITEM_SIZE,
    height: ITEM_SIZE,
    borderRadius: 12,
  },
});

Skeleton Loading

Skeleton screens provide perceived performance improvements by showing a preview of the content structure while data loads.

Shimmer Effect

import React, { useEffect } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withRepeat,
  withTiming,
  interpolate,
  Easing,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';

const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
const { width: SCREEN_WIDTH } = Dimensions.get('window');

interface SkeletonProps {
  width?: number | string;
  height?: number;
  borderRadius?: number;
  style?: any;
}

export function Skeleton({
  width = '100%',
  height = 20,
  borderRadius = 4,
  style,
}: SkeletonProps) {
  const translateX = useSharedValue(-SCREEN_WIDTH);
  
  useEffect(() => {
    translateX.value = withRepeat(
      withTiming(SCREEN_WIDTH, {
        duration: 1500,
        easing: Easing.inOut(Easing.ease),
      }),
      -1, // Infinite
      false // Don't reverse
    );
  }, []);
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));
  
  return (
    <View
      style={[
        {
          width,
          height,
          borderRadius,
          backgroundColor: '#E1E9EE',
          overflow: 'hidden',
        },
        style,
      ]}
    >
      <Animated.View style={[StyleSheet.absoluteFill, animatedStyle]}>
        <LinearGradient
          colors={['#E1E9EE', '#F2F8FC', '#E1E9EE']}
          start={{ x: 0, y: 0 }}
          end={{ x: 1, y: 0 }}
          style={[StyleSheet.absoluteFill, { width: SCREEN_WIDTH }]}
        />
      </Animated.View>
    </View>
  );
}

// Card skeleton composition
export function CardSkeleton() {
  return (
    <View style={styles.card}>
      <Skeleton width={60} height={60} borderRadius={30} />
      <View style={styles.cardContent}>
        <Skeleton width="70%" height={16} />
        <Skeleton width="50%" height={14} style={{ marginTop: 8 }} />
      </View>
    </View>
  );
}

// List skeleton
export function ListSkeleton({ count = 5 }: { count?: number }) {
  return (
    <View>
      {Array.from({ length: count }).map((_, index) => (
        <CardSkeleton key={index} />
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  card: {
    flexDirection: 'row',
    padding: 16,
    backgroundColor: 'white',
    marginBottom: 8,
    borderRadius: 12,
  },
  cardContent: {
    flex: 1,
    marginLeft: 12,
    justifyContent: 'center',
  },
});

Pulse Skeleton (Alternative)

import React, { useEffect } from 'react';
import { View } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withRepeat,
  withTiming,
  Easing,
} from 'react-native-reanimated';

interface PulseSkeletonProps {
  width?: number | string;
  height?: number;
  borderRadius?: number;
  style?: any;
}

export function PulseSkeleton({
  width = '100%',
  height = 20,
  borderRadius = 4,
  style,
}: PulseSkeletonProps) {
  const opacity = useSharedValue(0.3);
  
  useEffect(() => {
    opacity.value = withRepeat(
      withTiming(1, {
        duration: 800,
        easing: Easing.inOut(Easing.ease),
      }),
      -1,
      true // Reverse each time
    );
  }, []);
  
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
  }));
  
  return (
    <Animated.View
      style={[
        {
          width,
          height,
          borderRadius,
          backgroundColor: '#E1E9EE',
        },
        animatedStyle,
        style,
      ]}
    />
  );
}

// Usage with conditional rendering
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);
  
  if (loading) {
    return (
      <View style={styles.profile}>
        <PulseSkeleton width={80} height={80} borderRadius={40} />
        <PulseSkeleton width={150} height={24} style={{ marginTop: 16 }} />
        <PulseSkeleton width={100} height={16} style={{ marginTop: 8 }} />
      </View>
    );
  }
  
  return (
    <View style={styles.profile}>
      <Image source={{ uri: user.avatar }} style={styles.avatar} />
      <Text style={styles.name}>{user.name}</Text>
      <Text style={styles.email}>{user.email}</Text>
    </View>
  );
}

Button Animations

Well-animated buttons provide clear feedback and make interactions feel responsive. Here are several button animation patterns you can use throughout your apps.

Ripple Effect Button

import React, { useState } from 'react';
import { Pressable, View, StyleSheet, GestureResponderEvent } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  runOnJS,
} from 'react-native-reanimated';

interface RippleButtonProps {
  onPress?: () => void;
  children: React.ReactNode;
  style?: any;
  rippleColor?: string;
}

export function RippleButton({
  onPress,
  children,
  style,
  rippleColor = 'rgba(255, 255, 255, 0.3)',
}: RippleButtonProps) {
  const [ripplePosition, setRipplePosition] = useState({ x: 0, y: 0 });
  const scale = useSharedValue(0);
  const opacity = useSharedValue(0);
  
  const handlePressIn = (event: GestureResponderEvent) => {
    const { locationX, locationY } = event.nativeEvent;
    setRipplePosition({ x: locationX, y: locationY });
    
    scale.value = 0;
    opacity.value = 1;
    scale.value = withTiming(4, { duration: 400 });
  };
  
  const handlePressOut = () => {
    opacity.value = withTiming(0, { duration: 200 });
  };
  
  const rippleStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
    opacity: opacity.value,
  }));
  
  return (
    <Pressable
      onPress={onPress}
      onPressIn={handlePressIn}
      onPressOut={handlePressOut}
      style={[styles.button, style]}
    >
      <View style={styles.content}>
        {children}
      </View>
      
      <Animated.View
        style={[
          styles.ripple,
          {
            backgroundColor: rippleColor,
            left: ripplePosition.x - 50,
            top: ripplePosition.y - 50,
          },
          rippleStyle,
        ]}
      />
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    overflow: 'hidden',
    position: 'relative',
    backgroundColor: '#007AFF',
    borderRadius: 8,
  },
  content: {
    padding: 16,
    alignItems: 'center',
  },
  ripple: {
    position: 'absolute',
    width: 100,
    height: 100,
    borderRadius: 50,
  },
});

Loading Button

import React, { useEffect } from 'react';
import { Pressable, Text, View, StyleSheet, ActivityIndicator } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  withRepeat,
  Easing,
} from 'react-native-reanimated';

interface LoadingButtonProps {
  onPress: () => void;
  loading: boolean;
  disabled?: boolean;
  children: React.ReactNode;
  style?: any;
}

export function LoadingButton({
  onPress,
  loading,
  disabled,
  children,
  style,
}: LoadingButtonProps) {
  const scale = useSharedValue(1);
  const contentOpacity = useSharedValue(1);
  const loaderOpacity = useSharedValue(0);
  
  useEffect(() => {
    if (loading) {
      contentOpacity.value = withTiming(0, { duration: 150 });
      loaderOpacity.value = withTiming(1, { duration: 150 });
    } else {
      contentOpacity.value = withTiming(1, { duration: 150 });
      loaderOpacity.value = withTiming(0, { duration: 150 });
    }
  }, [loading]);
  
  const handlePressIn = () => {
    if (!loading && !disabled) {
      scale.value = withTiming(0.97, { duration: 100 });
    }
  };
  
  const handlePressOut = () => {
    scale.value = withTiming(1, { duration: 100 });
  };
  
  const buttonStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  
  const contentStyle = useAnimatedStyle(() => ({
    opacity: contentOpacity.value,
  }));
  
  const loaderStyle = useAnimatedStyle(() => ({
    opacity: loaderOpacity.value,
  }));
  
  return (
    <Pressable
      onPress={loading || disabled ? undefined : onPress}
      onPressIn={handlePressIn}
      onPressOut={handlePressOut}
    >
      <Animated.View
        style={[
          styles.button,
          disabled && styles.disabled,
          buttonStyle,
          style,
        ]}
      >
        <Animated.View style={[styles.contentContainer, contentStyle]}>
          {children}
        </Animated.View>
        
        <Animated.View style={[styles.loaderContainer, loaderStyle]}>
          <ActivityIndicator color="white" />
        </Animated.View>
      </Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#007AFF',
    paddingVertical: 14,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
    justifyContent: 'center',
    position: 'relative',
  },
  disabled: {
    backgroundColor: '#A0A0A0',
  },
  contentContainer: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  loaderContainer: {
    ...StyleSheet.absoluteFillObject,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

// Usage
function SubmitForm() {
  const [loading, setLoading] = useState(false);
  
  const handleSubmit = async () => {
    setLoading(true);
    await submitData();
    setLoading(false);
  };
  
  return (
    <LoadingButton loading={loading} onPress={handleSubmit}>
      <Text style={styles.buttonText}>Submit</Text>
    </LoadingButton>
  );
}

Success/Error Button State

import React, { useEffect } from 'react';
import { Pressable, Text, StyleSheet } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  withSequence,
  interpolateColor,
} from 'react-native-reanimated';

type ButtonState = 'idle' | 'loading' | 'success' | 'error';

interface StatefulButtonProps {
  state: ButtonState;
  onPress: () => void;
  idleText: string;
  loadingText?: string;
  successText?: string;
  errorText?: string;
}

export function StatefulButton({
  state,
  onPress,
  idleText,
  loadingText = 'Loading...',
  successText = 'Success!',
  errorText = 'Error',
}: StatefulButtonProps) {
  const stateProgress = useSharedValue(0);
  const scale = useSharedValue(1);
  
  useEffect(() => {
    switch (state) {
      case 'idle':
        stateProgress.value = withTiming(0, { duration: 200 });
        break;
      case 'loading':
        stateProgress.value = withTiming(1, { duration: 200 });
        break;
      case 'success':
        stateProgress.value = withTiming(2, { duration: 200 });
        // Celebratory bounce
        scale.value = withSequence(
          withTiming(1.1, { duration: 100 }),
          withTiming(1, { duration: 100 })
        );
        break;
      case 'error':
        stateProgress.value = withTiming(3, { duration: 200 });
        // Error shake
        scale.value = withSequence(
          withTiming(1.02, { duration: 50 }),
          withTiming(0.98, { duration: 50 }),
          withTiming(1.02, { duration: 50 }),
          withTiming(1, { duration: 50 })
        );
        break;
    }
  }, [state]);
  
  const buttonStyle = useAnimatedStyle(() => {
    const backgroundColor = interpolateColor(
      stateProgress.value,
      [0, 1, 2, 3],
      ['#007AFF', '#FF9500', '#34C759', '#FF3B30']
    );
    
    return {
      backgroundColor,
      transform: [{ scale: scale.value }],
    };
  });
  
  const getText = () => {
    switch (state) {
      case 'idle': return idleText;
      case 'loading': return loadingText;
      case 'success': return successText;
      case 'error': return errorText;
    }
  };
  
  const getIcon = () => {
    switch (state) {
      case 'success': return 'βœ“ ';
      case 'error': return 'βœ• ';
      default: return '';
    }
  };
  
  return (
    <Pressable
      onPress={state === 'idle' ? onPress : undefined}
      disabled={state !== 'idle'}
    >
      <Animated.View style={[styles.button, buttonStyle]}>
        <Text style={styles.buttonText}>
          {getIcon()}{getText()}
        </Text>
      </Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    paddingVertical: 14,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});

Page Transitions

Custom page transitions can significantly enhance the user experience by providing visual continuity between screens.

Shared Element Concept

flowchart LR
    subgraph Screen1["Screen A"]
        A[πŸ–ΌοΈ Image]
        B[Title]
        C[Subtitle]
    end
    
    subgraph Transition["Transition"]
        D[πŸ–ΌοΈ Image animates position/size]
        E[Content fades]
    end
    
    subgraph Screen2["Screen B"]
        F[πŸ–ΌοΈ Image - larger]
        G[Full content]
    end
    
    Screen1 --> Transition --> Screen2
    
    style Transition fill:#fff3e0

Hero Transition Pattern

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

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

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

interface HeroTransitionProps {
  items: Item[];
}

export function HeroTransition({ items }: HeroTransitionProps) {
  const [selectedItem, setSelectedItem] = useState<Item | null>(null);
  const [origin, setOrigin] = useState({ x: 0, y: 0, width: 0, height: 0 });
  
  const progress = useSharedValue(0);
  
  const openDetail = (item: Item, layout: any) => {
    setOrigin(layout);
    setSelectedItem(item);
    progress.value = withSpring(1, { damping: 15 });
  };
  
  const closeDetail = () => {
    progress.value = withSpring(0, { damping: 15 }, (finished) => {
      if (finished) {
        runOnJS(setSelectedItem)(null);
      }
    });
  };
  
  const backdropStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    pointerEvents: progress.value > 0.5 ? 'auto' : 'none',
  }));
  
  const detailContainerStyle = useAnimatedStyle(() => {
    const x = interpolate(progress.value, [0, 1], [origin.x, 0]);
    const y = interpolate(progress.value, [0, 1], [origin.y, 100]);
    const width = interpolate(progress.value, [0, 1], [origin.width, SCREEN_WIDTH]);
    const height = interpolate(progress.value, [0, 1], [origin.height, 300]);
    
    return {
      position: 'absolute',
      left: x,
      top: y,
      width,
      height,
    };
  });
  
  const contentStyle = useAnimatedStyle(() => ({
    opacity: interpolate(progress.value, [0.5, 1], [0, 1]),
    transform: [
      { translateY: interpolate(progress.value, [0.5, 1], [20, 0]) },
    ],
  }));
  
  const renderItem = (item: Item, index: number) => (
    <Pressable
      key={item.id}
      onPress={(event) => {
        event.target.measure((x, y, width, height, pageX, pageY) => {
          openDetail(item, { x: pageX, y: pageY, width, height });
        });
      }}
      style={styles.listItem}
    >
      <Image source={{ uri: item.image }} style={styles.thumbnail} />
      <Text style={styles.itemTitle}>{item.title}</Text>
    </Pressable>
  );
  
  return (
    <View style={styles.container}>
      {/* List */}
      <View style={styles.list}>
        {items.map(renderItem)}
      </View>
      
      {/* Detail overlay */}
      {selectedItem && (
        <>
          <Animated.View style={[styles.backdrop, backdropStyle]}>
            <Pressable 
              style={StyleSheet.absoluteFill} 
              onPress={closeDetail} 
            />
          </Animated.View>
          
          <Animated.View style={detailContainerStyle}>
            <Image 
              source={{ uri: selectedItem.image }} 
              style={styles.detailImage}
            />
          </Animated.View>
          
          <Animated.View style={[styles.detailContent, contentStyle]}>
            <Text style={styles.detailTitle}>{selectedItem.title}</Text>
            <Text style={styles.detailDescription}>
              {selectedItem.description}
            </Text>
          </Animated.View>
        </>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  list: { padding: 16 },
  listItem: {
    flexDirection: 'row',
    alignItems: 'center',
    marginBottom: 12,
    backgroundColor: 'white',
    borderRadius: 12,
    overflow: 'hidden',
  },
  thumbnail: {
    width: 80,
    height: 80,
  },
  itemTitle: {
    fontSize: 16,
    fontWeight: '600',
    marginLeft: 12,
  },
  backdrop: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
  },
  detailImage: {
    width: '100%',
    height: '100%',
    borderRadius: 12,
  },
  detailContent: {
    position: 'absolute',
    top: 420,
    left: 0,
    right: 0,
    padding: 20,
  },
  detailTitle: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 12,
  },
  detailDescription: {
    fontSize: 16,
    lineHeight: 24,
    color: '#666',
  },
});

Page Flip Transition

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

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

interface FlipPageProps {
  frontContent: React.ReactNode;
  backContent: React.ReactNode;
}

export function FlipPage({ frontContent, backContent }: FlipPageProps) {
  const [isFlipped, setIsFlipped] = useState(false);
  const rotation = useSharedValue(0);
  
  const flip = () => {
    const newFlipped = !isFlipped;
    setIsFlipped(newFlipped);
    rotation.value = withSpring(newFlipped ? 180 : 0, {
      damping: 15,
      stiffness: 100,
    });
  };
  
  const frontStyle = useAnimatedStyle(() => {
    const rotateY = interpolate(
      rotation.value,
      [0, 180],
      [0, 180]
    );
    
    return {
      transform: [
        { perspective: 1000 },
        { rotateY: `${rotateY}deg` },
      ],
      backfaceVisibility: 'hidden',
    };
  });
  
  const backStyle = useAnimatedStyle(() => {
    const rotateY = interpolate(
      rotation.value,
      [0, 180],
      [180, 360]
    );
    
    return {
      transform: [
        { perspective: 1000 },
        { rotateY: `${rotateY}deg` },
      ],
      backfaceVisibility: 'hidden',
    };
  });
  
  return (
    <Pressable onPress={flip} style={styles.container}>
      <Animated.View style={[styles.page, frontStyle]}>
        {frontContent}
      </Animated.View>
      
      <Animated.View style={[styles.page, styles.backPage, backStyle]}>
        {backContent}
      </Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  container: {
    width: SCREEN_WIDTH - 40,
    height: 200,
  },
  page: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: '#007AFF',
    borderRadius: 16,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  backPage: {
    backgroundColor: '#34C759',
  },
});

// Usage
function FlipCard() {
  return (
    <FlipPage
      frontContent={
        <View>
          <Text style={styles.cardTitle}>Front Side</Text>
          <Text style={styles.cardHint}>Tap to flip</Text>
        </View>
      }
      backContent={
        <View>
          <Text style={styles.cardTitle}>Back Side</Text>
          <Text style={styles.cardHint}>Hidden content revealed!</Text>
        </View>
      }
    />
  );
}

Hands-On Exercises

Exercise 1: Animated Toast Notification

Create a toast notification system with animated entrance and exit.

Requirements:

  • Slides in from the top with bounce
  • Auto-dismisses after 3 seconds
  • Supports different types (success, error, info)
  • Can be manually dismissed by swiping up
Show Solution
import React, { useEffect, useState, createContext, useContext } from 'react';
import { View, Text, StyleSheet, Dimensions, Pressable } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  runOnJS,
  useAnimatedGestureHandler,
} from 'react-native-reanimated';
import { PanGestureHandler } from 'react-native-gesture-handler';

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

type ToastType = 'success' | 'error' | 'info';

interface Toast {
  id: string;
  message: string;
  type: ToastType;
}

// Toast Context
const ToastContext = createContext<{
  showToast: (message: string, type?: ToastType) => void;
}>({ showToast: () => {} });

export function useToast() {
  return useContext(ToastContext);
}

// Individual Toast Component
function ToastItem({ 
  toast, 
  onDismiss 
}: { 
  toast: Toast; 
  onDismiss: (id: string) => void;
}) {
  const translateY = useSharedValue(-100);
  const opacity = useSharedValue(0);
  
  useEffect(() => {
    // Animate in
    translateY.value = withSpring(0, { damping: 12 });
    opacity.value = withTiming(1, { duration: 200 });
    
    // Auto dismiss after 3 seconds
    const timeout = setTimeout(() => {
      dismiss();
    }, 3000);
    
    return () => clearTimeout(timeout);
  }, []);
  
  const dismiss = () => {
    translateY.value = withTiming(-100, { duration: 200 });
    opacity.value = withTiming(0, { duration: 200 }, (finished) => {
      if (finished) {
        runOnJS(onDismiss)(toast.id);
      }
    });
  };
  
  const gestureHandler = useAnimatedGestureHandler({
    onActive: (event) => {
      if (event.translationY < 0) {
        translateY.value = event.translationY;
      }
    },
    onEnd: (event) => {
      if (event.translationY < -50 || event.velocityY < -500) {
        // Swipe up to dismiss
        translateY.value = withTiming(-100, { duration: 150 });
        opacity.value = withTiming(0, { duration: 150 }, (finished) => {
          if (finished) {
            runOnJS(onDismiss)(toast.id);
          }
        });
      } else {
        // Snap back
        translateY.value = withSpring(0, { damping: 12 });
      }
    },
  });
  
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: translateY.value }],
    opacity: opacity.value,
  }));
  
  const getColors = () => {
    switch (toast.type) {
      case 'success': return { bg: '#34C759', icon: 'βœ“' };
      case 'error': return { bg: '#FF3B30', icon: 'βœ•' };
      case 'info': return { bg: '#007AFF', icon: 'β„Ή' };
    }
  };
  
  const colors = getColors();
  
  return (
    <PanGestureHandler onGestureEvent={gestureHandler}>
      <Animated.View style={[styles.toast, animatedStyle]}>
        <View style={[styles.toastContent, { backgroundColor: colors.bg }]}>
          <Text style={styles.toastIcon}>{colors.icon}</Text>
          <Text style={styles.toastMessage}>{toast.message}</Text>
        </View>
      </Animated.View>
    </PanGestureHandler>
  );
}

// Toast Provider
export function ToastProvider({ children }: { children: React.ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([]);
  
  const showToast = (message: string, type: ToastType = 'info') => {
    const id = Date.now().toString();
    setToasts(prev => [...prev, { id, message, type }]);
  };
  
  const dismissToast = (id: string) => {
    setToasts(prev => prev.filter(t => t.id !== id));
  };
  
  return (
    <ToastContext.Provider value={{ showToast }}>
      {children}
      <View style={styles.toastContainer} pointerEvents="box-none">
        {toasts.map(toast => (
          <ToastItem 
            key={toast.id} 
            toast={toast} 
            onDismiss={dismissToast}
          />
        ))}
      </View>
    </ToastContext.Provider>
  );
}

const styles = StyleSheet.create({
  toastContainer: {
    position: 'absolute',
    top: 50,
    left: 0,
    right: 0,
    alignItems: 'center',
    zIndex: 1000,
  },
  toast: {
    marginBottom: 8,
  },
  toastContent: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingVertical: 12,
    paddingHorizontal: 20,
    borderRadius: 12,
    minWidth: SCREEN_WIDTH - 40,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.15,
    shadowRadius: 8,
    elevation: 4,
  },
  toastIcon: {
    fontSize: 18,
    color: 'white',
    marginRight: 12,
    fontWeight: 'bold',
  },
  toastMessage: {
    color: 'white',
    fontSize: 16,
    flex: 1,
  },
});

// Usage
function App() {
  return (
    <ToastProvider>
      <MyScreen />
    </ToastProvider>
  );
}

function MyScreen() {
  const { showToast } = useToast();
  
  return (
    <View>
      <Pressable onPress={() => showToast('Success!', 'success')}>
        <Text>Show Success</Text>
      </Pressable>
      <Pressable onPress={() => showToast('Error occurred', 'error')}>
        <Text>Show Error</Text>
      </Pressable>
    </View>
  );
}

Exercise 2: Animated Accordion

Build an accordion component with smooth expand/collapse animations.

Requirements:

  • Click header to expand/collapse content
  • Smooth height animation
  • Rotating chevron indicator
  • Support for multiple sections (only one open at a time optional)
Show Solution
import React, { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  measure,
  useAnimatedRef,
  runOnUI,
} from 'react-native-reanimated';

interface AccordionItemProps {
  title: string;
  children: React.ReactNode;
  isExpanded: boolean;
  onToggle: () => void;
}

function AccordionItem({ 
  title, 
  children, 
  isExpanded, 
  onToggle 
}: AccordionItemProps) {
  const contentRef = useAnimatedRef<Animated.View>();
  const height = useSharedValue(0);
  const rotation = useSharedValue(0);
  
  React.useEffect(() => {
    if (isExpanded) {
      // Measure content and animate to full height
      runOnUI(() => {
        'worklet';
        const measured = measure(contentRef);
        if (measured) {
          height.value = withTiming(measured.height, { duration: 300 });
        }
      })();
      rotation.value = withTiming(180, { duration: 300 });
    } else {
      height.value = withTiming(0, { duration: 300 });
      rotation.value = withTiming(0, { duration: 300 });
    }
  }, [isExpanded]);
  
  const containerStyle = useAnimatedStyle(() => ({
    height: height.value,
    overflow: 'hidden',
  }));
  
  const chevronStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${rotation.value}deg` }],
  }));
  
  return (
    <View style={styles.accordionItem}>
      <Pressable onPress={onToggle} style={styles.accordionHeader}>
        <Text style={styles.accordionTitle}>{title}</Text>
        <Animated.Text style={[styles.chevron, chevronStyle]}>
          β–Ό
        </Animated.Text>
      </Pressable>
      
      <Animated.View style={containerStyle}>
        <Animated.View 
          ref={contentRef}
          style={styles.accordionContent}
          collapsable={false}
        >
          {children}
        </Animated.View>
      </Animated.View>
    </View>
  );
}

interface AccordionSection {
  id: string;
  title: string;
  content: React.ReactNode;
}

interface AccordionProps {
  sections: AccordionSection[];
  allowMultiple?: boolean;
}

export function Accordion({ sections, allowMultiple = false }: AccordionProps) {
  const [expandedIds, setExpandedIds] = useState<string[]>([]);
  
  const toggleSection = (id: string) => {
    if (allowMultiple) {
      setExpandedIds(prev => 
        prev.includes(id) 
          ? prev.filter(i => i !== id)
          : [...prev, id]
      );
    } else {
      setExpandedIds(prev => 
        prev.includes(id) ? [] : [id]
      );
    }
  };
  
  return (
    <View style={styles.accordion}>
      {sections.map(section => (
        <AccordionItem
          key={section.id}
          title={section.title}
          isExpanded={expandedIds.includes(section.id)}
          onToggle={() => toggleSection(section.id)}
        >
          {section.content}
        </AccordionItem>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  accordion: {
    borderRadius: 12,
    overflow: 'hidden',
  },
  accordionItem: {
    backgroundColor: 'white',
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  accordionHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    padding: 16,
  },
  accordionTitle: {
    fontSize: 16,
    fontWeight: '600',
  },
  chevron: {
    fontSize: 12,
    color: '#666',
  },
  accordionContent: {
    padding: 16,
    paddingTop: 0,
  },
});

// Usage
function FAQScreen() {
  const sections = [
    {
      id: '1',
      title: 'How do I create an account?',
      content: <Text>To create an account, tap the Sign Up button...</Text>,
    },
    {
      id: '2',
      title: 'How do I reset my password?',
      content: <Text>Go to Settings > Account > Reset Password...</Text>,
    },
    {
      id: '3',
      title: 'How do I contact support?',
      content: <Text>You can reach us at support@example.com...</Text>,
    },
  ];
  
  return <Accordion sections={sections} />;
}

Exercise 3: Pull-to-Refresh Animation

Create a custom pull-to-refresh indicator with a unique animation.

Requirements:

  • Custom refresh indicator (not the default spinner)
  • Animate based on pull distance
  • Different animation when refreshing vs. pulling
  • Works with ScrollView or FlatList
Show Solution
import React, { useState } from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  useAnimatedScrollHandler,
  withSpring,
  withRepeat,
  withTiming,
  interpolate,
  Extrapolation,
  runOnJS,
  cancelAnimation,
} from 'react-native-reanimated';

const REFRESH_THRESHOLD = 80;

interface PullToRefreshProps {
  onRefresh: () => Promise<void>;
  children: React.ReactNode;
}

export function PullToRefresh({ onRefresh, children }: PullToRefreshProps) {
  const [refreshing, setRefreshing] = useState(false);
  const scrollY = useSharedValue(0);
  const pullProgress = useSharedValue(0);
  const refreshRotation = useSharedValue(0);
  
  const triggerRefresh = async () => {
    setRefreshing(true);
    
    // Start spinning animation
    refreshRotation.value = withRepeat(
      withTiming(360, { duration: 1000 }),
      -1,
      false
    );
    
    try {
      await onRefresh();
    } finally {
      cancelAnimation(refreshRotation);
      refreshRotation.value = withTiming(0, { duration: 200 });
      setRefreshing(false);
    }
  };
  
  const scrollHandler = useAnimatedScrollHandler({
    onScroll: (event) => {
      scrollY.value = event.contentOffset.y;
      
      if (event.contentOffset.y < 0) {
        pullProgress.value = Math.min(
          Math.abs(event.contentOffset.y) / REFRESH_THRESHOLD,
          1.5
        );
      } else {
        pullProgress.value = 0;
      }
    },
    onEndDrag: (event) => {
      if (event.contentOffset.y < -REFRESH_THRESHOLD && !refreshing) {
        runOnJS(triggerRefresh)();
      }
    },
  });
  
  // Indicator container style
  const indicatorContainerStyle = useAnimatedStyle(() => {
    const translateY = interpolate(
      pullProgress.value,
      [0, 1],
      [-60, 20],
      Extrapolation.CLAMP
    );
    
    return {
      transform: [{ translateY }],
      opacity: interpolate(
        pullProgress.value,
        [0, 0.5, 1],
        [0, 0.5, 1],
        Extrapolation.CLAMP
      ),
    };
  });
  
  // Dots animation
  const dot1Style = useAnimatedStyle(() => {
    const scale = refreshing 
      ? 1 
      : interpolate(
          pullProgress.value,
          [0, 0.3, 0.6, 1],
          [0.3, 1, 0.5, 1],
          Extrapolation.CLAMP
        );
    
    return {
      transform: [
        { scale },
        { rotate: `${refreshRotation.value}deg` },
      ],
    };
  });
  
  const dot2Style = useAnimatedStyle(() => {
    const scale = refreshing 
      ? 1 
      : interpolate(
          pullProgress.value,
          [0, 0.4, 0.7, 1],
          [0.3, 0.5, 1, 1],
          Extrapolation.CLAMP
        );
    
    return {
      transform: [
        { scale },
        { rotate: `${refreshRotation.value + 120}deg` },
      ],
    };
  });
  
  const dot3Style = useAnimatedStyle(() => {
    const scale = refreshing 
      ? 1 
      : interpolate(
          pullProgress.value,
          [0, 0.5, 0.8, 1],
          [0.3, 0.5, 0.8, 1],
          Extrapolation.CLAMP
        );
    
    return {
      transform: [
        { scale },
        { rotate: `${refreshRotation.value + 240}deg` },
      ],
    };
  });
  
  return (
    <View style={styles.container}>
      {/* Refresh Indicator */}
      <Animated.View style={[styles.indicatorContainer, indicatorContainerStyle]}>
        <View style={styles.dotsContainer}>
          <Animated.View style={[styles.dot, styles.dot1, dot1Style]} />
          <Animated.View style={[styles.dot, styles.dot2, dot2Style]} />
          <Animated.View style={[styles.dot, styles.dot3, dot3Style]} />
        </View>
        <Text style={styles.indicatorText}>
          {refreshing ? 'Refreshing...' : 'Pull to refresh'}
        </Text>
      </Animated.View>
      
      {/* Scrollable Content */}
      <Animated.ScrollView
        onScroll={scrollHandler}
        scrollEventThrottle={16}
        contentContainerStyle={styles.scrollContent}
      >
        {children}
      </Animated.ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  indicatorContainer: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    alignItems: 'center',
    zIndex: 10,
  },
  dotsContainer: {
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'center',
    height: 40,
  },
  dot: {
    width: 12,
    height: 12,
    borderRadius: 6,
    marginHorizontal: 4,
  },
  dot1: { backgroundColor: '#FF3B30' },
  dot2: { backgroundColor: '#34C759' },
  dot3: { backgroundColor: '#007AFF' },
  indicatorText: {
    fontSize: 12,
    color: '#666',
    marginTop: 4,
  },
  scrollContent: {
    paddingTop: 20,
  },
});

// Usage
function RefreshableList() {
  const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']);
  
  const handleRefresh = async () => {
    await new Promise(resolve => setTimeout(resolve, 2000));
    setItems(prev => [`New Item ${Date.now()}`, ...prev]);
  };
  
  return (
    <PullToRefresh onRefresh={handleRefresh}>
      {items.map((item, index) => (
        <View key={index} style={styles.listItem}>
          <Text>{item}</Text>
        </View>
      ))}
    </PullToRefresh>
  );
}

Summary

Animation patterns are the building blocks of engaging mobile experiences. By mastering these common patterns, you can create polished, professional apps that feel native and responsive.

🎯 Key Takeaways

  • Fade animations: Use for content transitions, modals, and visibility changes
  • Slide animations: Perfect for drawers, page transitions, and directional reveals
  • Scale and bounce: Provide tactile feedback for interactive elements
  • Staggered lists: Use built-in entering/exiting animations or custom delays for list items
  • Skeleton loading: Shimmer or pulse effects improve perceived performance
  • Button animations: Ripples, loading states, and success/error feedback enhance interactions
  • Page transitions: Hero animations and flips create visual continuity

Animation Pattern Quick Reference

Pattern Best Animation Type Use Case
Fade In/Out withTiming Modals, tooltips, transitions
Slide withSpring Drawers, sheets, menus
Press feedback withSpring (stiff) Buttons, cards, list items
Bounce/Pop withSpring (bouncy) Notifications, badges, alerts
Loading pulse withRepeat + withTiming Skeletons, activity indicators
Staggered list withDelay + any Lists, grids, onboarding

In the next lesson, we'll explore React Native Gesture Handler to create touch interactions that work seamlessly with these animations.