Flip Card

A 3D flip card that smoothly rotates

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-haptics @sbaiahmed1/react-native-blur

Copy 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