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:
- Shows a "Log" label with a plus icon
- When pressed, the text fades out and the button shrinks to a circle
- The icon rotates 45° — the plus becomes an "X" close symbol
- A horizontal list of meal types slides in from above
- 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>
);
});
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',
},
});
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);
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]);
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]);
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]);
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]);
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
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>
);
};
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;
};
}, []);
AnimatedPressable
We create an animated version of Pressable to animate the button width:
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
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>
);
};
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]);
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>
)}
4. Accessibility
Don't forget accessibility! Add proper labels:
<Pressable
accessibilityLabel={isOpen.value ? 'Close menu' : 'Log meal'}
accessibilityRole="button"
accessibilityState={{ expanded: isOpen.value }}
>
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>
Conclusion
This animated FAB demonstrates how combining multiple Reanimated animations can create a smooth, delightful user experience. The key takeaways:
-
Use
useSharedValuefor state that drives animations — it's more performant than React state -
Create separate
useAnimatedStylehooks for each animated element — keeps code organized -
Use appropriate easing functions —
Easing.out(Easing.ease)gives a natural deceleration - Keep animations short — 200-300ms feels responsive without being jarring
-
Combine transforms — mixing
translateY,scale, andopacitycreates richer effects
*Have questions or suggestions? Drop a comment below!

Top comments (0)