← Back to Blog
·8 min read

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.

Fintech Card UI — Live Demo
Good morning,
Sarah
S
•••• •••• •••• 4291
Card Holder
SARAH ABUTEEN
Expires
09/27
•••
CVV
VISA
This card is property of NeoBank Ltd.
Tap card to flip

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.

perspective

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.

transformStyle: preserve-3d

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.

backfaceVisibility: hidden

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.

rotateY interpolation

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.

rotateY — tap to flip
Front
Back
rotateX — tap to flip
Front
Back

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

perspective goes inside transform, not as a direct style

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.

backfaceVisibility requires absolute positioning

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.

Android devices may need elevation set to zero

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.

Wrap your root with GestureHandlerRootView

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.

ScrollView and gesture conflicts need explicit handling

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.