DEV Community

Kavuma Herbert
Kavuma Herbert

Posted on

How to create Facebook inspired Animated Reactions with React Native, react-native-reanimated and Typescript

Facebook inspired Animated Reactions with React Native, react-native-reanimated and Typescript

I’m excited to share my new React Native package: react-native-animated-reactions. This package makes it effortless to add animated reaction bars (think: like, love, laugh, wow, sad, and angry) to your React Native apps. In this post, I’ll walk you through what the package does, how it works under the hood, and how you can integrate it into your own projects.

What Is React Native Animated Reactions?

At its core, the package provides a customizable ReactionBar component that displays a row of animated reaction icons. It leverages react-native-reanimated to deliver smooth, natural animations that look great on both iOS and Android devices. The component is built with performance and simplicity in mind, so you can add interactive reactions to your user interface with just a few lines of code.

In case you don't have time to go through this post, you can go straight to GitHub repository Available here
Please don't forget to give it a star

If you want to test it on your phone or play with it, you can use this expo snack

Lets dive Into the Code

Create index.tsx file to host ReactionBar component code

Defining Types

export type Reaction = {
  id: string;
  iconUrl: string;
};

type ReactionBarProps = {
  onDismiss: () => void;
  onReactionSelect: (reaction: Reaction) => void;
};

export type ReactionBarRef = {
  showReactions: () => void;
  dismissReactions: () => void;
};
Enter fullscreen mode Exit fullscreen mode
  • Reaction: A simple type defining each reaction with an ID and URL.
  • ReactionBarProps: Props expected by the component include callbacks for when the bar is dismissed or when a reaction is selected.
  • ReactionBarRef: The imperative methods that the parent can call to control the component.

Component Definition with forwardRef.

const ReactionBar = forwardRef<ReactionBarRef, ReactionBarProps>(
  ({ onDismiss, onReactionSelect }, ref) => {
    // ...
  }
);
Enter fullscreen mode Exit fullscreen mode
  • forwardRef is used so that parent components can directly call methods defined inside ReactionBar.

Setting Up Animation Shared Values

const scale = useSharedValue(0);
const bounce = useSharedValue(1);
Enter fullscreen mode Exit fullscreen mode
  • scale: Controls the overall scale (and opacity, as mapped later) of the reaction bar.
  • bounce: Controls the bounce animation effect when a reaction is selected.

Memoizing the Reaction Data

const iconUrls: string[] = [
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Flike.png?alt=media&token=42e892f7-f7a0-4f6b-82cd-2ff8844d4483',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Flove.png?alt=media&token=f762c537-f639-4925-9c1a-7400636eea68',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Fhaha.png?alt=media&token=0dc5cdf9-84cd-4ed1-9a16-7ed1200131a7',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Fwow.png?alt=media&token=9637475f-92d8-4910-9666-ec049e9de65a',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Fsad.png?alt=media&token=d1990a7e-5bed-4a0e-b9f8-8837503abf4c',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Fangry.png?alt=media&token=8d0c6bf4-fcc0-4283-821f-d51b77064ea3',
];

const reactions = useMemo<Reaction[]>(
  () => [
    { id: 'like', iconUrl: iconUrls[0] },
    { id: 'love', iconUrl: iconUrls[1] },
    { id: 'haha', iconUrl: iconUrls[2] },
    { id: 'wow', iconUrl: iconUrls[3] },
    { id: 'sad', iconUrl: iconUrls[4] },
    { id: 'angry', iconUrl: iconUrls[5] },
  ],
  []
);
Enter fullscreen mode Exit fullscreen mode
  • useMemo ensures the reaction list is only computed once unless dependencies change.
  • I decided to use remote versions of icon pictures to make it easy to store the same icons in the database if you want otherwise you can use local icons

Animated Styles

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

const bounceStyle = useAnimatedStyle(() => ({
  transform: [{ scale: bounce.value }],
}));
Enter fullscreen mode Exit fullscreen mode
  • reactionBarStyle: Uses the scale value to animate both the scale and opacity.
  • bounceStyle: Applies a bounce effect on each reaction icon.

Animation Functions

Show Reactions

const showReactions = useCallback(() => {
  scale.value = withSpring(1, { damping: 10, stiffness: 100 });
}, [scale]);
Enter fullscreen mode Exit fullscreen mode
  • Animates the scale from 0 to 1 with a spring effect, making the reaction bar appear. Dismiss Reactions
const dismissReactions = useCallback(() => {
  scale.value = withTiming(0, { duration: 200 });
}, [scale]);
Enter fullscreen mode Exit fullscreen mode
  • Animates the scale from 1 back to 0 using a timing animation, hiding the bar.

Exposing Imperative Methods

useImperativeHandle(ref, () => ({
  showReactions,
  dismissReactions,
}));
Enter fullscreen mode Exit fullscreen mode
  • Exposes the showReactions and dismissReactions functions to the parent component via the ref.

Handling Reaction Selection

const handleReactionSelect = useCallback(
  (id: string) => {
    const selectedReaction = reactions.find((reaction) => reaction.id === id);
    if (selectedReaction) {
      onReactionSelect(selectedReaction);
    }

    // Trigger bounce effect
    bounce.value = 1.5;
    bounce.value = withSpring(1, { damping: 5, stiffness: 150 });

    dismissReactions();
  },
  [bounce, dismissReactions, onReactionSelect, reactions]
);
Enter fullscreen mode Exit fullscreen mode
  • Finds the selected reaction and calls onReactionSelect.
  • Triggers a bounce effect by temporarily increasing bounce.value.
  • Calls dismissReactions to hide the reaction bar after selection.
  • Using useCallback with the proper dependency array ensures that this function remains stable across renders unless one of its dependencies changes, which is essential for performance optimization in React applications.

Listening to Animation Changes

useAnimatedReaction(
  () => scale.value === 0,
  (isDismissed) => {
    if (isDismissed) {
      runOnJS(onDismiss)();
    }
  }
);
Enter fullscreen mode Exit fullscreen mode
  • Monitors scale.value and when it becomes 0 (bar is hidden), it calls onDismiss using runOnJS.

Component styles

const styles = StyleSheet.create({
  reactionBar: {
    flexDirection: 'row',
    backgroundColor: '#FFFFFF',
    borderRadius: 30,
    padding: 10,
    position: 'absolute',
    bottom: 100,
    alignSelf: 'center',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 3 },
    shadowOpacity: 0.2,
    shadowRadius: 4,
    elevation: 5,
  },
  reactionIconWrapper: {
    marginHorizontal: 9,
  },
  reactionIcon: {
    width: 32,
    height: 32,
  },
Enter fullscreen mode Exit fullscreen mode
  • Here, we define the styles of the Reaction component.

Rendering the Component

return (
  <Animated.View style={[styles.reactionBar, reactionBarStyle]}>
    {reactions.map((reaction) => (
      <TouchableOpacity
        key={reaction.id}
        onPress={() => handleReactionSelect(reaction.id)}
        style={styles.reactionIconWrapper}
      >
        <Animated.Image
          source={{ uri: reaction.iconUrl }}
          style={[styles.reactionIcon, bounceStyle]}
        />
      </TouchableOpacity>
    ))}
  </Animated.View>
);
Enter fullscreen mode Exit fullscreen mode
  • Renders an animated view with the reaction bar style.
  • Maps over reactions to create a TouchableOpacity for each reaction, wrapping an animated image.
  • Tapping an icon triggers handleReactionSelect.

Full code

import { useImperativeHandle, forwardRef, useCallback, useMemo } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  useAnimatedReaction,
  runOnJS,
} from 'react-native-reanimated';

const iconUrls: string[] = [
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Flike.png?alt=media&token=42e892f7-f7a0-4f6b-82cd-2ff8844d4483',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Flove.png?alt=media&token=f762c537-f639-4925-9c1a-7400636eea68',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Fhaha.png?alt=media&token=0dc5cdf9-84cd-4ed1-9a16-7ed1200131a7',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Fwow.png?alt=media&token=9637475f-92d8-4910-9666-ec049e9de65a',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Fsad.png?alt=media&token=d1990a7e-5bed-4a0e-b9f8-8837503abf4c',
  'https://firebasestorage.googleapis.com/v0/b/firecast-6a07f.appspot.com/o/icons%2Fangry.png?alt=media&token=8d0c6bf4-fcc0-4283-821f-d51b77064ea3',
];

export type Reaction = {
  id: string;
  iconUrl: string;
};

type ReactionBarProps = {
  onDismiss: () => void;
  onReactionSelect: (reaction: Reaction) => void; // Callback for selected reaction
};

export type ReactionBarRef = {
  showReactions: () => void;
  dismissReactions: () => void;
};

const ReactionBar = forwardRef<ReactionBarRef, ReactionBarProps>(
  ({ onDismiss, onReactionSelect }, ref) => {
    // Reanimated shared values for animations
    const scale = useSharedValue(0);
    const bounce = useSharedValue(1);

    const reactions = useMemo<Reaction[]>(
      () => [
        { id: 'like', iconUrl: iconUrls[0]! },
        { id: 'love', iconUrl: iconUrls[1]! },
        { id: 'haha', iconUrl: iconUrls[2]! },
        { id: 'wow', iconUrl: iconUrls[3]! },
        { id: 'sad', iconUrl: iconUrls[4]! },
        { id: 'angry', iconUrl: iconUrls[5]! },
      ],
      []
    );

    // Animated styles
    const reactionBarStyle = useAnimatedStyle(() => ({
      transform: [{ scale: scale.value }],
      opacity: scale.value,
    }));

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

    // Show reactions
    const showReactions = useCallback(() => {
      scale.value = withSpring(1, { damping: 10, stiffness: 100 });
    }, [scale]);

    // Dismiss reactions
    const dismissReactions = useCallback(() => {
      scale.value = withTiming(0, { duration: 200 });
    }, [scale]);

    // Handle reaction selection
    const handleReactionSelect = useCallback(
      (id: string) => {
        const selectedReaction = reactions.find(
          (reaction) => reaction.id === id
        );
        if (selectedReaction) {
          onReactionSelect(selectedReaction);
        }

        // Trigger bounce effect
        bounce.value = 1.5;
        bounce.value = withSpring(1, { damping: 5, stiffness: 150 });

        dismissReactions();
      },
      [bounce, dismissReactions, onReactionSelect, reactions]
    );

    useAnimatedReaction(
      () => scale.value === 0,
      (isDismissed) => {
        if (isDismissed) {
          runOnJS(onDismiss)();
        }
      }
    );

    // Expose methods to parent component
    useImperativeHandle(ref, () => ({
      showReactions,
      dismissReactions,
    }));

    return (
      <Animated.View style={[styles.reactionBar, reactionBarStyle]}>
        {reactions.map((reaction) => (
          <TouchableOpacity
            key={reaction.id}
            onPress={() => handleReactionSelect(reaction.id)}
            style={styles.reactionIconWrapper}
          >
            <Animated.Image
              source={{ uri: reaction.iconUrl }}
              style={[styles.reactionIcon, bounceStyle]}
            />
          </TouchableOpacity>
        ))}
      </Animated.View>
    );
  }
);

const styles = StyleSheet.create({
  reactionBar: {
    flexDirection: 'row',
    backgroundColor: '#FFFFFF',
    borderRadius: 30,
    padding: 10,
    position: 'absolute',
    bottom: 100,
    alignSelf: 'center',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 3 },
    shadowOpacity: 0.2,
    shadowRadius: 4,
    elevation: 5,
  },
  reactionIconWrapper: {
    marginHorizontal: 9,
  },
  reactionIcon: {
    width: 32,
    height: 32,
  },
});

export default ReactionBar;
Enter fullscreen mode Exit fullscreen mode

Component usage in real world

Create app.tsx to use our ReactionBar component

import { useRef, useState } from 'react';
import { Image, Pressable,StyleSheet, Text, TouchableOpacity, View,} from 'react-native';
import ReactionBar, { type ReactionBarRef,type Reaction} from '.';

const App = () => {
  // Here we use ReactionBarRef and Reaction types exported in index.tsx
  const reactionBarRef = useRef<ReactionBarRef>(null);
  const [selectedReaction, setSelectedReaction] = useState<Reaction | null>(
    null
  );
  // Here, we get access to the selected reaction, you can save it to database or anything else 
  const handleReactionSelect = (reaction: Reaction) => {
    console.log('Selected Reaction:', reaction);
    setSelectedReaction(reaction);
  };

  const onDismiss = () => {
    console.log('Reaction bar dismissed');
  };

  const showReaction = () => {
    // Here we access showReactions method exposed by ReactionBar through the reference
    reactionBarRef.current?.showReactions();
  };

  const dismissReaction = () => {
// Here, we access the dismissReactions method exposed by ReactionBar through the reference.
    reactionBarRef.current?.dismissReactions();
  };

  return (
    <Pressable style={styles.container} onPress={dismissReaction}>
      {// If there is selected item show it on screen}
      {selectedReaction && (
        <View style={styles.selectedItem}>
          <Text style={styles.selectedReactionText}>
            Selected Reaction: {selectedReaction.id}
          </Text>
          <Image
            source={{ uri: selectedReaction.iconUrl }}
            style={styles.selectedIcon}
          />
        </View>
      )}

      <TouchableOpacity onPress={showReaction} style={styles.triggerButton}>
        <Text style={styles.triggerButtonText}>Show Reactions</Text>
      </TouchableOpacity>

      <ReactionBar
        ref={reactionBarRef}
        onReactionSelect={handleReactionSelect}
        onDismiss={onDismiss}
      />
    </Pressable>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  triggerButton: {
    padding: 10,
    backgroundColor: '#007BFF',
    borderRadius: 5,
  },
  triggerButtonText: {
    color: '#FFFFFF',
    fontWeight: 'bold',
  },
  selectedReactionText: {
    fontSize: 16,
    color: '#333',
  },
  selectedItem: {
    marginBottom: 20,
    flexDirection: 'row',
    gap: 30,
  },
  selectedIcon: {
    width: 32,
    height: 32,
  },
});

export default App;
Enter fullscreen mode Exit fullscreen mode

In case you want to use gestures like long press, as is the case with Facebook and WhatsApp

We need to use react-native-gesture-handler, you can follow the setup and installation here

import { GestureHandlerRootView, LongPressGestureHandler, State } from 'react-native-gesture-handler';
mport { useRef, useState } from 'react';
import { Text,TouchableOpacity} from 'react-native';
import ReactionBar, { type ReactionBarRef, type Reaction} from '.';
const App = ()=>{
 const reactionBarRef = useRef<ReactionBarRef>(null);
  const [selectedReaction, setSelectedReaction] = useState<Reaction | null>(null);
  const handleReactionSelect = (reaction: Reaction) => {
    console.log('Selected Reaction:', reaction);
    setSelectedReaction(reaction);
  };

  const onDismiss = () => {
    console.log('Reaction bar dismissed');
  };


  return (
    <GestureHandlerRootView>
      <LongPressGestureHandler
        onHandlerStateChange={({ nativeEvent }) => {
          if (nativeEvent.state === State.ACTIVE) {
            reactionBarRef.current?.showReactions();
          }
        }}
        minDurationMs={500}
      >
        <TouchableOpacity>
          <Text>Press & Hold</Text>
        </TouchableOpacity>
      </LongPressGestureHandler>
       <ReactionBar
        ref={reactionBarRef}
        onReactionSelect={handleReactionSelect}
        onDismiss={onDismiss}
      />
    </GestureHandlerRootView>
  )
}

Enter fullscreen mode Exit fullscreen mode

Summary

  • Imperative Control: The component uses forwardRef and useImperativeHandle to expose showReactions and dismissReactions for external control.
  • Real-Time Animations: Shared values (scale and bounce) and animated styles (useAnimatedStyle) provide smooth transitions.
  • User Interaction: Tapping an icon triggers a bounce animation, dismisses the bar, and calls the provided callback with the selected reaction.
  • Reactive Updates: useAnimatedReaction monitors changes in scale.value and calls onDismiss when the bar is hidden.

Refferences

Final Thoughts

The ReactionBar component is designed to bring fun, interactive, and smooth animated reactions to your app with minimal hassle. With an easy-to-use API, sensible defaults, and the flexibility to extend and customize, it’s a great tool for any React Native developer looking to enhance user interactions.

I hope this breakdown of this component's source code and features helps you understand its inner workings and inspires you to try it out in your next project. If you have any questions, suggestions, or ideas for improvement, feel free to comment or reach out on GitHub. The more optimized and maintained version is available on npm

I am available for consultation and Gigs for the advanced apps React, React-native, Nextjs, Nodejs, Firebase and more

Top comments (0)