Card Flip Animation in React Native with React Native Reanimated
- React Native
- React Native Reanimated
- Animation
- TypeScript
One of the most satisfying micro interactions in a fintech app is the credit card flip. You tap the card, it rotates 180 degrees, the front face fades away and the back reveals the CVV. It feels native, polished, and alive.
Building a card flip animation in React Native correctly requires understanding three things: how animated values interpolate into rotation transforms, how backfaceVisibility hides the wrong face at the right time, and how React Native Reanimated shared values make the whole thing run at 60fps on the native UI thread.
Interactive Card Flip Demo
This is the same card flip from the fintech app UI. Tap the card or use the buttons below. Notice the easing curve, the perspective depth, and how the back face stays completely invisible until the rotation passes 90 degrees.
How the React Native Card Flip Animation Works
The flip effect is built from three CSS and React Native concepts layered together. Understanding each one individually makes the full implementation straightforward.
Applied to the parent container. Controls how deep the 3D space feels. Lower values like 400 to 600 create dramatic depth. Higher values like 1000 and above produce a subtle, flat effect.
Applied to the inner container. Tells the rendering engine that children should live in the same 3D space instead of being flattened into 2D layers.
Applied to each card face. Makes a face invisible when it rotates more than 90 degrees away from the viewer so you never see a face through the back of the other.
A shared value from 0 to 1 interpolates to rotateY 0 degrees through 180 degrees for the front face, and 180 degrees through 360 degrees for the back face. This ensures only one face is ever visible at any given point in the animation.
Comparing Y Axis and X Axis Card Flips
The most common card flip animation uses the Y axis, which creates a horizontal flip like turning a page. The X axis flip works well for vertical reveals, similar to lifting a lid or flipping a panel upward.
Implementing the Card Flip with React Native Reanimated
In React Native, CSS transitions are not available. React Native Reanimated solves this by running animations entirely on the native UI thread, ensuring 60fps performance even while the JavaScript thread is busy processing other logic.
Step 1: Create the shared value and flip function
import {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
export function useCardFlip() {
// 0 = front face showing, 1 = back face showing
const isFlipped = useSharedValue(0);
const flip = () => {
isFlipped.value = withTiming(
isFlipped.value === 0 ? 1 : 0,
{
duration: 700,
easing: Easing.bezier(0.4, 0.0, 0.2, 1),
}
);
};
return { isFlipped, flip };
}Step 2: Derive animated styles for each card face
// Front face rotates from 0 to 180 degrees and disappears after 90
const frontAnimStyle = useAnimatedStyle(() => {
const rotateY = interpolate(
isFlipped.value,
[0, 1],
[0, 180]
);
return {
transform: [{ rotateY: `${rotateY}deg` }],
};
});
// Back face starts at 180 degrees and rotates to 360 which equals 0
const backAnimStyle = useAnimatedStyle(() => {
const rotateY = interpolate(
isFlipped.value,
[0, 1],
[180, 360]
);
return {
transform: [{ rotateY: `${rotateY}deg` }],
};
});Step 3: Build the JSX structure with Animated Views
import Animated from 'react-native-reanimated';
// The container needs perspective to create 3D depth
<Animated.View style={styles.cardContainer}>
{/* Front face */}
<Animated.View
style={[styles.face, styles.front, frontAnimStyle]}
>
<CardFront />
</Animated.View>
{/* Back face starts rotated and hidden behind the front */}
<Animated.View
style={[styles.face, styles.back, backAnimStyle]}
>
<CardBack />
</Animated.View>
</Animated.View>Step 4: Define the StyleSheet with perspective and backfaceVisibility
const styles = StyleSheet.create({
cardContainer: {
width: 300,
height: 180,
// perspective lives inside transform[] in React Native, NOT as a direct style prop
transform: [{ perspective: 900 }],
},
face: {
...StyleSheet.absoluteFillObject,
borderRadius: 20,
// This is what hides the wrong side during rotation
backfaceVisibility: 'hidden',
},
front: {
// default state: visible
},
back: {
// starts pre-rotated so it is invisible by default
},
});Why use React Native Reanimated instead of the built in Animated API? The standard React Native Animated API runs animations on the JavaScript thread. Any JS work happening simultaneously will cause dropped frames during the flip. React Native Reanimated uses useSharedValue and withTiming to run animations entirely on the native UI thread, guaranteeing 60fps performance even in complex screens with heavy logic.
Adding Tap Gesture Support with Gesture Handler
For a more premium feel, pair the card flip with react-native-gesture-handler and use the Tap gesture instead of TouchableOpacity. It responds faster and integrates directly with Reanimated worklets, eliminating the bridge hop between the JS and UI threads.
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const tapGesture = Gesture.Tap()
.onEnd(() => {
// Runs directly on the UI thread with no JS bridge delay
isFlipped.value = withTiming(
isFlipped.value === 0 ? 1 : 0,
{ duration: 700, easing: Easing.bezier(0.4, 0, 0.2, 1) }
);
});
<GestureDetector gesture={tapGesture}>
<Animated.View style={styles.cardContainer}>
{/* ... card faces */}
</Animated.View>
</GestureDetector>Common Gotchas When Building Card Flip Animations
Unlike CSS, React Native's ViewStyle does not accept perspective as a top level property. TypeScript will error. It must live inside transform: transform: [{ perspective: 900 }]. Apply it on the container, not the rotating view itself.
Both card faces must use StyleSheet.absoluteFillObject so they occupy the same space and overlap completely. Without absolute positioning, the front and back faces will stack vertically, and the flip animation will not work as expected.
On some Android devices, backfaceVisibility: 'hidden' does not work reliably without also setting elevation: 0 on each face View. Always test on a real Android device before shipping to production.
GestureDetector must be a descendant of GestureHandlerRootView. If it is not, you will get a hard render error at runtime: "GestureDetector must be used as a descendant of GestureHandlerRootView." Wrap your app root once in App.tsx or _layout.tsx and you are covered everywhere. Do not forget style={{ flex: 1 }} because without it the root collapses to zero height.
If your card lives inside a ScrollView, handle gesture priority explicitly. Otherwise scroll and tap gestures fight each other. Use Gesture.Simultaneous() or Gesture.Exclusive() to declare which wins.
Choosing the Right Easing Curve for Card Animations
A linear flip feels mechanical and robotic. The right easing curve is what gives the animation a natural, physical feel. Easing.bezier(0.4, 0.0, 0.2, 1) is the Material Design standard easing curve. It accelerates quickly at the start and decelerates gently at the end, which mimics the physical behavior of flipping a real card in your hand.
For a snappier feel, try Easing.out(Easing.cubic). For something more gentle and symmetric, use Easing.inOut(Easing.sin).
Animation duration sweet spot: A 600 to 800 millisecond duration works best for card flip animations. Below 400ms and the flip feels like a glitch. Above 900ms and users think the app is lagging. 700ms with the bezier curve above is the recommended starting point for production fintech apps.
Complete FlipCard Component for React Native
flip-card.tsx
import React from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
useSharedValue, useAnimatedStyle,
withTiming, interpolate, Easing,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
export function FlipCard({ front, back }: { front: React.ReactNode; back: React.ReactNode }) {
const isFlipped = useSharedValue(0);
const tapGesture = Gesture.Tap().onEnd(() => {
isFlipped.value = withTiming(
isFlipped.value ? 0 : 1,
{ duration: 700, easing: Easing.bezier(0.4, 0, 0.2, 1) }
);
});
const frontStyle = useAnimatedStyle(() => ({
transform: [{ rotateY: `${interpolate(isFlipped.value, [0, 1], [0, 180])}deg` }],
}));
const backStyle = useAnimatedStyle(() => ({
transform: [{ rotateY: `${interpolate(isFlipped.value, [0, 1], [180, 360])}deg` }],
}));
return (
<GestureDetector gesture={tapGesture}>
<View style={styles.container}>
<Animated.View style={[styles.face, frontStyle]}>
{front}
</Animated.View>
<Animated.View style={[styles.face, backStyle]}>
{back}
</Animated.View>
</View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
container: {
width: 300,
height: 180,
// perspective must live inside transform[] — not a top-level style prop
transform: [{ perspective: 900 }],
},
face: {
...StyleSheet.absoluteFillObject,
borderRadius: 20,
backfaceVisibility: 'hidden',
},
});Usage — passing front and back
<FlipCard
front={
<View style={cardStyles.cardFront}>
<View style={cardStyles.cardTop}>
<View style={cardStyles.chip} />
<View style={cardStyles.logoRow}>
<View style={cardStyles.logoCircle1} />
<View style={cardStyles.logoCircle2} />
</View>
</View>
<Text style={cardStyles.cardNumber}>•••• •••• •••• 4291</Text>
<View style={cardStyles.cardBottom}>
<View>
<Text style={cardStyles.holderLabel}>Card Holder</Text>
<Text style={cardStyles.cardName}>SARAH ABUTEEN</Text>
</View>
<View>
<Text style={cardStyles.expiryLabel}>Expires</Text>
<Text style={cardStyles.expiry}>09/27</Text>
</View>
</View>
</View>
}
back={
<View style={cardStyles.cardBack}>
<View style={cardStyles.magstripe} />
<View style={cardStyles.backContent}>
<View style={cardStyles.cvvRow}>
<View style={cardStyles.cvvStrip}>
<Text style={cardStyles.cvvNumber}>•••</Text>
</View>
<Text style={cardStyles.cvvLabel}>CVV</Text>
</View>
<View style={cardStyles.backLogoRow}>
<Text style={cardStyles.visaText}>VISA</Text>
</View>
</View>
</View>
}
/>Card styles
const cardStyles = StyleSheet.create({
// Front
cardFront: {
flex: 1,
borderRadius: 20,
padding: 20,
justifyContent: 'space-between',
backgroundColor: '#0d1b40',
borderWidth: 1,
borderColor: 'rgba(96,165,250,0.2)',
overflow: 'hidden', // prevents content bleeding during rotation
},
cardTop: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
chip: {
width: 32,
height: 24,
borderRadius: 5,
backgroundColor: '#d4a843',
},
logoRow: {
flexDirection: 'row',
},
logoCircle1: {
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: '#eb001b',
},
logoCircle2: {
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: '#f79e1b',
marginLeft: -10,
opacity: 0.9,
},
cardNumber: {
fontFamily: 'DMMono',
fontSize: 15,
letterSpacing: 3,
color: 'rgba(255,255,255,0.85)',
},
cardBottom: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
},
holderLabel: {
fontSize: 8,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: 'rgba(255,255,255,0.35)',
marginBottom: 3,
},
cardName: {
fontSize: 13,
fontWeight: '600',
color: 'rgba(255,255,255,0.9)',
letterSpacing: 1,
},
expiryLabel: {
fontSize: 8,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: 'rgba(255,255,255,0.35)',
marginBottom: 3,
textAlign: 'right',
},
expiry: {
fontFamily: 'DMMono',
fontSize: 13,
color: 'rgba(255,255,255,0.9)',
textAlign: 'right',
},
// Back
cardBack: {
flex: 1,
borderRadius: 20,
backgroundColor: '#111828',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.07)',
overflow: 'hidden',
},
magstripe: {
height: 40,
marginTop: 28,
backgroundColor: '#1e1e1e',
},
backContent: {
flex: 1,
padding: 16,
gap: 10,
},
cvvRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
cvvStrip: {
flex: 1,
height: 34,
backgroundColor: '#fff',
borderRadius: 4,
alignItems: 'flex-end',
justifyContent: 'center',
paddingRight: 12,
},
cvvNumber: {
fontFamily: 'DMMono',
fontSize: 14,
color: '#222',
letterSpacing: 2,
},
cvvLabel: {
fontSize: 10,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: 'rgba(255,255,255,0.4)',
},
backLogoRow: {
alignItems: 'flex-end',
},
visaText: {
fontSize: 20,
fontWeight: '800',
fontStyle: 'italic',
color: 'rgba(255,255,255,0.7)',
letterSpacing: -0.5,
},
});App root setup
GestureDetector requires GestureHandlerRootView somewhere above it in the tree. Otherwise you will get a render error at runtime. Wrap your app root once and forget about it:
// App.tsx
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
// the rest of your app
</GestureHandlerRootView>
);
}
// If using Expo Router — app/_layout.tsx
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack />
</GestureHandlerRootView>
);
}This reusable FlipCard component accepts any React Native content for the front and back faces, making it easy to use across different screens in your app whether you need a credit card flip, a flashcard, or any other two sided interactive element.