Flip Card
A 3D flip card that smoothly rotates
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-haptics @sbaiahmed1/react-native-blurCopy and paste the following code into your project.
component/base/flip-card.tsx
import React, { createContext, memo, useContext, useState } from "react";import { View, Pressable, StyleSheet, Platform, ViewStyle } from "react-native";import Animated, { useSharedValue, useAnimatedStyle, withTiming, interpolate, Extrapolation, useAnimatedProps, Easing, withSpring,} from "react-native-reanimated";import { BlurView, type BlurViewProps } from "@sbaiahmed1/react-native-blur";import * as Haptics from "expo-haptics";import type { FlipCardBackProps, FlipCardFrontProps, FlipCardContextValue, FlipCardProps, FlipCardTriggerProps,} from "./types";const AnimatedBlurView = Animated.createAnimatedComponent<BlurViewProps>(BlurView);const FlipCardContext = createContext<FlipCardContextValue | null>(null);const useFlipCard = (): FlipCardContextValue => { const context = useContext<FlipCardContextValue | null>(FlipCardContext); if (!context) { throw new Error( "FlipCard compound components must be used within FlipCard", ); } return context;};export const FlipCard: React.FC<FlipCardProps> & { Front: React.FC<FlipCardFrontProps>; Back: React.FC<FlipCardBackProps>; Trigger: React.FC<FlipCardTriggerProps>;} = ({ children, width = 340, height = 480, borderRadius = 24, blurIntensity = 90, containerStyle, animationDuration = 600, enableHaptics = true, onFlip, blurTint, scaleOnPress = true,}: FlipCardProps): | (React.ReactElement & React.ReactNode & React.JSX.Element) | null => { const [isFlipped, setIsFlipped] = useState<boolean>(false); const rotation = useSharedValue<number>(0); const scale = useSharedValue<number>(1); const flip = () => { if (enableHaptics) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } const newFlippedState = !isFlipped; setIsFlipped(newFlippedState); rotation.value = withTiming<number>(newFlippedState ? 180 : 0, { duration: animationDuration, easing: Easing.inOut(Easing.cubic), }); onFlip?.(newFlippedState); }; return ( <FlipCardContext.Provider value={{ isFlipped, flip, width, height, borderRadius, blurIntensity, animationDuration, rotation, scale, tint: blurTint || "light", scaleEnabled: scaleOnPress, }} > <View style={[styles.container, containerStyle, { width, height }]}> {children} </View> </FlipCardContext.Provider> );};const Front: React.FC<FlipCardFrontProps> & React.FunctionComponent<FlipCardFrontProps> = memo<FlipCardFrontProps>( ({ children, style, }: FlipCardFrontProps): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const { rotation, scale, width, height, borderRadius, blurIntensity, tint, }: FlipCardContextValue = useFlipCard(); const frontAnimatedStylez = useAnimatedStyle< Pick<ViewStyle, "transform" | "opacity"> >(() => { const rotateY = interpolate( rotation.value, [0, 180], [0, 180], Extrapolation.CLAMP, ); const opacity = interpolate( rotation.value, [0, 90, 90.01, 180], [1, 1, 0, 0], Extrapolation.CLAMP, ); return { transform: [ { perspective: 1000 }, { rotateY: `${rotateY}deg` }, { scale: scale.value }, ], opacity, }; }); const frontBlurPropz = useAnimatedProps<Pick<BlurViewProps, "blurAmount">>( () => { const intensity = rotation.value <= 20 ? withSpring<number>( interpolate( rotation.value, [0, 20], [0, blurIntensity], Extrapolation.CLAMP, ), ) : rotation.value >= 160 ? withSpring<number>( interpolate( rotation.value, [160, 180], [blurIntensity, 0], Extrapolation.CLAMP, ), ) : blurIntensity; return { blurAmount: intensity, }; }, ); return ( <Animated.View style={[ styles.card, { width, height, borderRadius }, frontAnimatedStylez, style, ]} > {children} {Platform.OS === "ios" && ( <AnimatedBlurView blurType={tint} animatedProps={frontBlurPropz} style={[ StyleSheet.absoluteFill, { borderRadius, overflow: "hidden" }, ]} /> )} </Animated.View> ); },);const Back: React.FC<FlipCardBackProps> & React.FunctionComponent<FlipCardBackProps> = memo<FlipCardBackProps>( ({ children, style, }: FlipCardBackProps): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const { rotation, scale, width, height, borderRadius, blurIntensity, tint, }: FlipCardContextValue = useFlipCard(); const backAnimatedStylez = useAnimatedStyle< Pick<ViewStyle, "transform" | "opacity"> >(() => { const rotateY = interpolate( rotation.value, [0, 180], [180, 360], Extrapolation.CLAMP, ); const opacity = interpolate( rotation.value, [0, 89.99, 90, 180], [0, 0, 1, 1], Extrapolation.CLAMP, ); return { transform: [ { perspective: 1000 }, { rotateY: `${rotateY}deg` }, { scale: scale.value }, ], opacity, }; }); const backBlurPropz = useAnimatedProps<Pick<BlurViewProps, "blurAmount">>( () => { const intensity = rotation.value >= 160 ? withSpring( interpolate( rotation.value, [180, 160], [0, blurIntensity], Extrapolation.CLAMP, ), ) : rotation.value <= 20 ? withSpring( interpolate( rotation.value, [20, 0], [blurIntensity, 0], Extrapolation.CLAMP, ), ) : blurIntensity; return { blurAmount: intensity, }; }, ); return ( <Animated.View style={[ styles.card, { width, height, borderRadius }, backAnimatedStylez, style, ]} > {children} {Platform.OS === "ios" && ( <AnimatedBlurView blurType={tint} animatedProps={backBlurPropz} style={[ StyleSheet.absoluteFill, { borderRadius, overflow: "hidden" }, ]} /> )} </Animated.View> ); },);const Trigger: React.FC<FlipCardTriggerProps> & React.FunctionComponent<FlipCardTriggerProps> = memo<FlipCardTriggerProps>( ({ children, asChild, ...props }: FlipCardTriggerProps): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const { flip, scale, scaleEnabled }: FlipCardContextValue = useFlipCard(); const onPressIn = () => { if (!scaleEnabled) return; scale.value = withTiming<number>(0.95, { duration: 100 }); }; const onPressOut = () => { if (!scaleEnabled) return; scale.value = withTiming<number>(1, { duration: 200 }); }; if (asChild && React.isValidElement(children)) { return React.cloneElement(children as React.ReactElement, { onPress: flip, onPressIn, onPressOut, ...props, }); } return ( <Pressable onPress={flip} onPressIn={onPressIn} onPressOut={onPressOut} style={StyleSheet.absoluteFill} {...props} > {children} </Pressable> ); },);FlipCard.Front = Front;FlipCard.Back = Back;FlipCard.Trigger = Trigger;const styles = StyleSheet.create({ container: { alignItems: "center", justifyContent: "center", }, card: Platform.OS === "android" ? { position: "absolute", backgroundColor: "#1a1a1a", overflow: "hidden", backfaceVisibility: "hidden", } : { position: "absolute", backgroundColor: "#1a1a1a", shadowColor: "#000", shadowOffset: { width: 0, height: 12, }, shadowOpacity: 0.3, shadowRadius: 16, elevation: 12, overflow: "hidden", backfaceVisibility: "hidden", },});Usage
import React, { type FC } from "react";import { FlipCard } from "@/components/base/flip-card";import { LinearGradient } from "expo-linear-gradient";import { StyleSheet, View, Text, StatusBar } from "react-native";import { SafeAreaView } from "react-native-safe-area-context";import { useFonts } from "expo-font";export default function App<T extends FC<{}>>(): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), }); if (!fontLoaded) return null; return ( <SafeAreaView style={styles.safeArea}> <StatusBar barStyle="light-content" /> <FlipCard width={340} height={320} animationDuration={700} borderRadius={16} enableHaptics scaleOnPress={Boolean(true)} blurTint="dark" blurIntensity={30} > <FlipCard.Front> <LinearGradient style={styles.gradient} colors={["#FF385C", "#E31C5F", "#BD1E59"]} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} /> <View style={styles.frontContent}> <View style={styles.topRow}> <View style={styles.guestFavorite}> <Text style={styles.guestFavoriteText}>Guest favorite</Text> </View> <View style={styles.rating}> <Text style={styles.star}>★</Text> <Text style={styles.ratingText}>4.92</Text> </View> </View> <View style={styles.mainContent}> <Text style={styles.propertyType}>Entire home</Text> <Text style={styles.propertyName}>Beachfront Villa</Text> <Text style={styles.location}>Malibu, California</Text> </View> <View style={styles.bottomRow}> <View style={styles.priceBlock}> <Text style={styles.price}>$385</Text> <Text style={styles.perNight}>night</Text> </View> <Text style={styles.tapHint}>Tap for details</Text> </View> </View> </FlipCard.Front> <FlipCard.Back> <LinearGradient style={styles.gradient} colors={["#222222", "#1a1a1a"]} start={{ x: 0, y: 0 }} end={{ x: 0, y: 1 }} /> <View style={styles.backContent}> <Text style={styles.backTitle}>What this place offers</Text> <View style={styles.amenitiesGrid}> <View style={styles.amenityItem}> <Text style={styles.amenityText}>Ocean view</Text> </View> <View style={styles.amenityItem}> <Text style={styles.amenityText}>Pool</Text> </View> <View style={styles.amenityItem}> <Text style={styles.amenityText}>Fast wifi</Text> </View> <View style={styles.amenityItem}> <Text style={styles.amenityText}>Free parking</Text> </View> </View> <View style={styles.divider} /> <View style={styles.statsRow}> <View style={styles.stat}> <Text style={styles.statNumber}>4</Text> <Text style={styles.statLabel}>guests</Text> </View> <View style={styles.stat}> <Text style={styles.statNumber}>2</Text> <Text style={styles.statLabel}>bedrooms</Text> </View> <View style={styles.stat}> <Text style={styles.statNumber}>2</Text> <Text style={styles.statLabel}>baths</Text> </View> </View> <View style={styles.reserveButton}> <Text style={styles.reserveText}>Reserve</Text> </View> </View> </FlipCard.Back> <FlipCard.Trigger asChild={false} /> </FlipCard> </SafeAreaView> );}const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: "#0a0a0a", alignItems: "center", marginTop: 50, }, gradient: { ...StyleSheet.absoluteFillObject, }, frontContent: { flex: 1, justifyContent: "space-between", padding: 28, }, topRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", }, guestFavorite: { backgroundColor: "rgba(255, 255, 255, 0.95)", paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, }, guestFavoriteText: { fontFamily: "SfProRounded", fontSize: 12, fontWeight: "600", color: "#222222", }, rating: { flexDirection: "row", alignItems: "center", gap: 4, }, star: { fontSize: 14, color: "#ffffff", }, ratingText: { fontFamily: "SfProRounded", fontSize: 15, fontWeight: "600", color: "#ffffff", }, mainContent: { gap: 6, }, propertyType: { fontFamily: "SfProRounded", fontSize: 14, color: "rgba(255, 255, 255, 0.7)", textTransform: "uppercase", letterSpacing: 1, }, propertyName: { fontFamily: "SfProRounded", fontSize: 36, fontWeight: "700", color: "#ffffff", letterSpacing: -0.5, }, location: { fontFamily: "SfProRounded", fontSize: 17, color: "rgba(255, 255, 255, 0.85)", }, bottomRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end", }, priceBlock: { flexDirection: "row", alignItems: "baseline", gap: 5, }, price: { fontFamily: "SfProRounded", fontSize: 32, fontWeight: "700", color: "#ffffff", }, perNight: { fontFamily: "SfProRounded", fontSize: 16, color: "rgba(255, 255, 255, 0.8)", }, tapHint: { fontFamily: "SfProRounded", fontSize: 13, color: "rgba(255, 255, 255, 0.5)", }, backContent: { flex: 1, padding: 28, justifyContent: "space-between", }, backTitle: { fontFamily: "SfProRounded", fontSize: 20, fontWeight: "600", color: "#ffffff", }, amenitiesGrid: { flexDirection: "row", flexWrap: "wrap", gap: 12, marginTop: 10, }, amenityItem: { width: "47%", flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 12, backgroundColor: "rgba(255, 255, 255, 0.08)", paddingHorizontal: 16, paddingVertical: 10, borderRadius: 12, }, amenityIcon: { fontSize: 20, }, amenityText: { fontFamily: "SfProRounded", fontSize: 12, color: "#ffffff", }, divider: { height: 0.5, backgroundColor: "rgba(255, 255, 255, 0.1)", marginTop: 10, }, statsRow: { flexDirection: "row", justifyContent: "space-around", }, stat: { alignItems: "center", gap: 4, }, statNumber: { fontFamily: "SfProRounded", fontSize: 16, fontWeight: "600", color: "#ffffff", }, statLabel: { fontFamily: "SfProRounded", fontSize: 11.5, color: "rgba(255, 255, 255, 0.6)", }, reserveButton: { backgroundColor: "#FF385C", paddingVertical: 12, borderRadius: 12, marginTop: 12, alignItems: "center", }, reserveText: { fontFamily: "SfProRounded", fontSize: 12, fontWeight: "600", color: "#ffffff", },});Props
FlipCardContextValue
React Native Reanimated
Expo Haptics
React Native Blur
