DEV Community

Aleksei Kharitonov
Aleksei Kharitonov

Posted on

Creating an Animated FAB with Button Flow in React Native

Recently, while working on a Food Tracker app, I needed a way for users to quickly log their meals. I wanted something more engaging than a standard modal or dropdown — a floating action button that expands into a horizontal list of meal types with smooth animations.

In this article, I'll walk you through how I built this animated FAB component using React Native Reanimated.

What We're Building

A floating action button positioned at the bottom-right of the screen that:

  1. Shows a "Log" label with a plus icon
  2. When pressed, the text fades out and the button shrinks to a circle
  3. The icon rotates 45° — the plus becomes an "X" close symbol
  4. A horizontal list of meal types slides in from above
  5. Selecting an option closes the menu and triggers the action

Prerequisites

  • React Native (0.70+)
  • react-native-reanimated (v3+)
  • react-native-svg (for the icon)

Basic Component Structure

Let's start with the basic structure of our component:

import React, { useCallback, useMemo } from 'react';
import { Pressable, ScrollView, Text, View } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
} from 'react-native-reanimated';

const BUTTON_HEIGHT = 48;
const LIST_BOTTOM_OFFSET = BUTTON_HEIGHT;
const ANIMATION_DURATION = 300;

const MEALS = [
  { type: 'breakfast', label: 'Breakfast' },
  { type: 'lunch', label: 'Lunch' },
  { type: 'dinner', label: 'Dinner' },
  { type: 'snack', label: 'Snack' },
];

type TProps = {
  onPress: (type: string) => void;
};

export const LogButton: React.FC<TProps> = ({ onPress }) => {
  const isOpen = useSharedValue(false);

  const toggleMenu = useCallback(() => {
    isOpen.value = !isOpen.value;
  }, [isOpen]);

  return (
    <View style={styles.container}>
      {/* List of options */}
      <Animated.View style={styles.listContainer}>
        <ScrollView horizontal>
          {/* Meal type items */}
        </ScrollView>
      </Animated.View>

      {/* Main FAB button */}
      <Pressable style={styles.mainButton} onPress={toggleMenu}>
        {/* Icon and text */}
      </Pressable>
    </View>
  );
});
Enter fullscreen mode Exit fullscreen mode

Styling

Here's the stylesheet for our component. Nothing fancy — just positioning and some shadows for depth:

import { StyleSheet } from 'react-native';

const BUTTON_HEIGHT = 48;
const ITEM_HEIGHT = 40;

export const styles = StyleSheet.create({
  container: {
    position: 'absolute',
    right: 16,
    bottom: 16,
    alignItems: 'flex-end',
    width: '100%',
  },
  listContainer: {
    position: 'absolute',
    bottom: 0,
    right: -16,
    height: 50,
    alignItems: 'flex-end',
  },
  scrollContent: {
    paddingHorizontal: 16,
    gap: 4,
  },
  mealItem: {
    height: ITEM_HEIGHT,
    justifyContent: 'center',
    alignItems: 'center',
    paddingHorizontal: 12,
    paddingVertical: 10,
    backgroundColor: 'white',
    shadowColor: 'black',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 3,
    elevation: 3,
    borderRadius: 16,
  },
  mainButton: {
    height: BUTTON_HEIGHT,
    borderRadius: BUTTON_HEIGHT / 2,
    backgroundColor: '#FF3B30',
    justifyContent: 'center',
    alignItems: 'center',
    flexDirection: 'row',
    shadowColor: 'black',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.3,
    shadowRadius: 4,
    elevation: 5,
    zIndex: 100,
    overflow: 'hidden',
  },
  label: {
    fontSize: 19,
    fontWeight: '600',
    lineHeight: 24,
    letterSpacing: -0.38,
    color: 'white',
  },
});
Enter fullscreen mode Exit fullscreen mode

The Icon Component

I use a simple SVG plus icon that rotates to become an "X" when the menu opens:

import React, { memo } from 'react';
import { Path, Svg, SvgProps } from 'react-native-svg';

interface IconProps extends SvgProps {
  size?: number;
  color?: string;
}

const TrackIconComponent: React.FC<IconProps> = ({ color = 'white', size = 20, ...rest }) => {
  return (
    <Svg width={size} height={size} viewBox="0 0 20 20" fill="none" {...rest}>
      <Path
        fillRule="evenodd"
        clipRule="evenodd"
        d="M8.5 17.5C8.5 18.3284 9.17157 19 10 19C10.8284 19 11.5 18.3284 11.5 17.5V11.5H17.5C18.3284 11.5 19 10.8284 19 10C19 9.17157 18.3284 8.5 17.5 8.5H11.5V2.5C11.5 1.67157 10.8284 1 10 1C9.17157 1 8.5 1.67157 8.5 2.5V8.5H2.5C1.67157 8.5 1 9.17157 1 10C1 10.8284 1.67157 11.5 2.5 11.5H8.5V17.5Z"
        fill={color}
      />
    </Svg>
  );
};

export const TrackIcon = memo(TrackIconComponent);
Enter fullscreen mode Exit fullscreen mode

The key insight: a plus sign rotated 45° becomes an X. This gives users a visual cue that the button now closes the menu.

The Magic: Reanimated Animations

Now comes the interesting part. We need four different animated styles working together:

1. FAB Width Animation

When opened, the button shrinks from a pill shape (102px) to a circle (48px):

const fabStyle = useAnimatedStyle(() => {
  return {
    width: withTiming(isOpen.value ? BUTTON_HEIGHT : 102, {
      duration: ANIMATION_DURATION,
      easing: Easing.out(Easing.ease),
    }),
  };
}, [isOpen]);
Enter fullscreen mode Exit fullscreen mode

I use Easing.out(Easing.ease) for a natural deceleration at the end of the animation.

2. Text Fade Out

The "Log" text smoothly disappears with three animated properties:

const logTextStyle = useAnimatedStyle(() => {
  return {
    opacity: withTiming(isOpen.value ? 0 : 1, { duration: 200 }),
    width: withTiming(isOpen.value ? 0 : 35, { duration: 200 }),
    marginLeft: withTiming(isOpen.value ? 0 : 8, { duration: 200 }),
    overflow: 'hidden',
  };
}, [isOpen]);
Enter fullscreen mode Exit fullscreen mode

Animating opacity, width, and marginLeft simultaneously creates a smooth collapse effect. The overflow: 'hidden' ensures the text doesn't spill out during the animation.

3. Icon Rotation

The icon rotates 45° to transform the plus into an X:

const iconStyle = useAnimatedStyle(() => {
  return {
    transform: [
      {
        rotate: withTiming(isOpen.value ? '45deg' : '0deg', {
          duration: 300,
        }),
      },
    ],
  };
}, [isOpen]);
Enter fullscreen mode Exit fullscreen mode

4. List Slide-In

The horizontal list slides up, scales up, and fades in:

const listStyle = useAnimatedStyle(() => {
  return {
    transform: [
      {
        translateY: withTiming(isOpen.value ? -LIST_BOTTOM_OFFSET : 0, {
          duration: ANIMATION_DURATION,
          easing: Easing.out(Easing.ease),
        }),
      },
      { scale: withTiming(isOpen.value ? 1 : 0.5, { duration: 200 }) },
    ],
    opacity: withTiming(isOpen.value ? 1 : 0, { duration: 200 }),
  };
}, [isOpen]);
Enter fullscreen mode Exit fullscreen mode

Combining translateY, scale, and opacity creates a dynamic "pop" entrance. The scale starts at 0.5 and grows to 1, making the list appear to emerge from the button.

Putting It All Together

Example FAB

Here's the complete component:

import React, { useCallback, useEffect, useMemo } from 'react';
import { Pressable, ScrollView, Text, View } from 'react-native';

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

import { TrackIcon } from './Icon';
import { styles } from './styles';

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

const BUTTON_HEIGHT = 48;
const LIST_BOTTOM_OFFSET = BUTTON_HEIGHT;
const ANIMATION_DURATION = 300;

const MEALS = [
  { type: 'breakfast', label: 'Breakfast' },
  { type: 'lunch', label: 'Lunch' },
  { type: 'dinner', label: 'Dinner' },
  { type: 'snack', label: 'Snack' },
];

type TProps = {
  onPress: (type: string) => void;
};

export const LogButton: React.FC<TProps> = ({ onPress }) => {
  const isOpen = useSharedValue(false);

  const toggleMenu = useCallback(() => {
    isOpen.value = !isOpen.value;
  }, [isOpen]);

  const renderMealTypes = useMemo(
    () =>
      MEALS.map(meal => {
        const handleMealPress = () => {
          onPress(meal.type);
          isOpen.value = false;
        };
        return (
          <Pressable key={meal.type} style={styles.mealItem} onPress={handleMealPress}>
            <Text>{meal.label}</Text>
          </Pressable>
        );
      }),
    [onPress, isOpen]
  );

  const listStyle = useAnimatedStyle(() => {
    return {
      transform: [
        {
          translateY: withTiming(isOpen.value ? -LIST_BOTTOM_OFFSET : 0, {
            duration: ANIMATION_DURATION,
            easing: Easing.out(Easing.ease),
          }),
        },
        { scale: withTiming(isOpen.value ? 1 : 0.5, { duration: 200 }) },
      ],
      opacity: withTiming(isOpen.value ? 1 : 0, { duration: 200 }),
    };
  }, [isOpen]);

  const iconStyle = useAnimatedStyle(() => {
    return {
      transform: [
        {
          rotate: withTiming(isOpen.value ? '45deg' : '0deg', {
            duration: 300,
          }),
        },
      ],
    };
  }, [isOpen]);

  const logTextStyle = useAnimatedStyle(() => {
    return {
      opacity: withTiming(isOpen.value ? 0 : 1, { duration: 200 }),
      width: withTiming(isOpen.value ? 0 : 35, { duration: 200 }),
      marginLeft: withTiming(isOpen.value ? 0 : 8, { duration: 200 }),
      overflow: 'hidden',
    };
  }, [isOpen]);

  const fabStyle = useAnimatedStyle(() => {
    return {
      width: withTiming(isOpen.value ? BUTTON_HEIGHT : 102, {
        duration: ANIMATION_DURATION,
        easing: Easing.out(Easing.ease),
      }),
    };
  }, [isOpen]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      isOpen.value = false;
    };
  }, []);

  return (
    <View style={styles.container} pointerEvents="box-none">
      <Animated.View style={[styles.listContainer, listStyle]}>
        <ScrollView
          horizontal
          showsHorizontalScrollIndicator={false}
          contentContainerStyle={styles.scrollContent}
        >
          {renderMealTypes}
        </ScrollView>
      </Animated.View>

      <AnimatedPressable style={[styles.mainButton, fabStyle]} onPress={toggleMenu}>
        <Animated.View style={iconStyle}>
          <TrackIcon size={18} color="white" />
        </Animated.View>
        <Animated.View style={logTextStyle}>
          <Text style={styles.label} numberOfLines={1}>
            Log
          </Text>
        </Animated.View>
      </AnimatedPressable>
    </View>
  );
};
Enter fullscreen mode Exit fullscreen mode

Key Implementation Details

pointerEvents="box-none"

This is crucial. Without it, the container would block all touch events on the content behind it. box-none allows touches to pass through the container while still capturing events on its children (the button and menu items).

Cleanup in useEffect

When the component unmounts while the menu is open, we reset the isOpen shared value. This prevents potential issues if the component gets remounted:

useEffect(() => {
  return () => {
    isOpen.value = false;
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

AnimatedPressable

We create an animated version of Pressable to animate the button width:

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
Enter fullscreen mode Exit fullscreen mode

This is a common Reanimated pattern when you need to animate props on a native component.

Potential Improvements

Here are some ideas to take this component further:

1. Staggered Animation for Menu Items

Make each meal type appear with a slight delay for a more polished look:

const MealItem = ({ meal, index, isOpen, onPress }) => {
  const itemStyle = useAnimatedStyle(() => ({
    opacity: withTiming(isOpen.value ? 1 : 0, { duration: 200 }),
    transform: [
      {
        translateY: withTiming(isOpen.value ? 0 : 10, {
          duration: 200,
          delay: index * 50, // Stagger effect
        }),
      },
    ],
  }));

  return (
    <Animated.View style={itemStyle}>
      <Pressable style={styles.mealItem} onPress={onPress}>
        <Text>{meal.label}</Text>
      </Pressable>
    </Animated.View>
  );
};
Enter fullscreen mode Exit fullscreen mode

2. Haptic Feedback

Add tactile feedback when the menu opens:

import * as Haptics from 'expo-haptics';

const toggleMenu = useCallback(() => {
  Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
  isOpen.value = !isOpen.value;
}, [isOpen]);
Enter fullscreen mode Exit fullscreen mode

3. Backdrop Overlay

Add a semi-transparent backdrop that dismisses the menu when tapped:

// Add to styles:
backdrop: {
  ...StyleSheet.absoluteFillObject,
  backgroundColor: 'black',
},

// In component:
const backdropStyle = useAnimatedStyle(() => ({
  opacity: withTiming(isOpen.value ? 0.3 : 0),
}));

// In render (before the container):
{isOpen.value && (
  <Animated.View style={[styles.backdrop, backdropStyle]}>
    <Pressable style={StyleSheet.absoluteFill} onPress={() => isOpen.value = false} />
  </Animated.View>
)}
Enter fullscreen mode Exit fullscreen mode

4. Accessibility

Don't forget accessibility! Add proper labels:

<Pressable
  accessibilityLabel={isOpen.value ? 'Close menu' : 'Log meal'}
  accessibilityRole="button"
  accessibilityState={{ expanded: isOpen.value }}
>
Enter fullscreen mode Exit fullscreen mode

5. Close on Outside Tap

Use a Pressable wrapper to detect taps outside the menu:

<Pressable style={StyleSheet.absoluteFill} onPress={() => isOpen.value = false}>
  <View style={styles.container} pointerEvents="box-none">
    {/* ... rest of component */}
  </View>
</Pressable>
Enter fullscreen mode Exit fullscreen mode

Conclusion

This animated FAB demonstrates how combining multiple Reanimated animations can create a smooth, delightful user experience. The key takeaways:

  1. Use useSharedValue for state that drives animations — it's more performant than React state
  2. Create separate useAnimatedStyle hooks for each animated element — keeps code organized
  3. Use appropriate easing functionsEasing.out(Easing.ease) gives a natural deceleration
  4. Keep animations short — 200-300ms feels responsive without being jarring
  5. Combine transforms — mixing translateY, scale, and opacity creates richer effects

*Have questions or suggestions? Drop a comment below!

Top comments (0)