AniUI

Flip Card

Tap-to-flip card with horizontal, vertical, or depth 3D rotation, mid-flip glass blur, and haptic feedback.

Web preview — components render natively on iOS & Android
import { FlipCard } from "@/components/ui/flip-card";
<FlipCard front={<CardFront />} back={<CardBack />} />

Installation#

npx @aniui/cli add flip-card

Usage#

import { FlipCard } from "@/components/ui/flip-card";
<FlipCard front={<CardFront />} back={<CardBack />} />

Source#

components/ui/flip-card.tsx
import React, { useEffect, useState } from "react";
import { Platform, Pressable, View } from "react-native";
import Animated, {
  Extrapolation,
  interpolate,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import * as Haptics from "expo-haptics";
import { cn } from "@/lib/utils";

const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);

const FLIP_SPRING = { dampingRatio: 0.72, duration: 600 } as const;
const PRESS_SPRING = { damping: 18, stiffness: 320, mass: 0.5 } as const;

export type FlipCardAnimation = "horizontal" | "vertical" | "depth";

/** Per-face 3D transform for a given flip mode. `offsetDeg` is 0 for the front face, 180 for the back. */
function faceTransform(mode: FlipCardAnimation, progressValue: number, offsetDeg: 0 | 180, scaleValue: number) {
  "worklet";
  const angle = offsetDeg + progressValue * 180;
  const midFlipBulge = Math.sin(Math.PI * progressValue);

  if (mode === "vertical") {
    return [{ perspective: 1000 }, { rotateX: `${angle}deg` }, { scale: scaleValue }];
  }
  if (mode === "depth") {
    return [
      { perspective: 850 },
      { translateY: -24 * midFlipBulge },
      { rotateY: `${angle}deg` },
      { scale: scaleValue * (1 - 0.15 * midFlipBulge) },
    ];
  }
  return [{ perspective: 1000 }, { rotateY: `${angle}deg` }, { scale: scaleValue }];
}

export interface FlipCardProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  front: React.ReactNode;
  back: React.ReactNode;
  flipped?: boolean;
  onFlip?: (flipped: boolean) => void;
  /** 3D rotation axis used for the flip. Defaults to the classic horizontal card flip. */
  animation?: FlipCardAnimation;
}

export function FlipCard({ className, front, back, flipped, onFlip, animation = "horizontal", ...props }: FlipCardProps) {
  const [internal, setInternal] = useState(false);
  const isFlipped = flipped ?? internal;
  const progress = useSharedValue(0);
  const scale = useSharedValue(1);

  useEffect(() => {
    progress.value = withSpring(isFlipped ? 1 : 0, FLIP_SPRING);
  }, [isFlipped, progress]);

  const frontStyle = useAnimatedStyle(() => ({
    transform: faceTransform(animation, progress.value, 0, scale.value),
    opacity: progress.value < 0.5 ? 1 : 0,
  }));
  const backStyle = useAnimatedStyle(() => ({
    transform: faceTransform(animation, progress.value, 180, scale.value),
    opacity: progress.value >= 0.5 ? 1 : 0,
  }));
  const edgeBlurStyle = useAnimatedStyle(() => ({
    opacity: interpolate(progress.value, [0, 0.5, 1], [0, 1, 0], Extrapolation.CLAMP),
  }));

  const toggle = () => {
    const next = !isFlipped;
    if (flipped === undefined) setInternal(next);
    onFlip?.(next);
    Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
  };

  const onPressIn = () => {
    scale.value = withSpring(0.96, PRESS_SPRING);
  };
  const onPressOut = () => {
    scale.value = withSpring(1, PRESS_SPRING);
  };

  return (
    <Pressable
      onPress={toggle}
      onPressIn={onPressIn}
      onPressOut={onPressOut}
      accessible={true}
      accessibilityRole="button"
      accessibilityState={{ expanded: isFlipped }}
      className={cn("min-h-12 min-w-12", className)}
      {...props}
    >
      <View style={{ transform: [{ perspective: 1000 }] }}>
        <Animated.View style={[{ backfaceVisibility: "hidden" }, frontStyle]}>
          {front}
          {Platform.OS === "ios" && (
            <AnimatedBlurView tint="light" intensity={90} pointerEvents="none" style={[{ position: "absolute", inset: 0 }, edgeBlurStyle]} />
          )}
        </Animated.View>
        <Animated.View style={[{ position: "absolute", inset: 0, backfaceVisibility: "hidden" }, backStyle]}>
          {back}
          {Platform.OS === "ios" && (
            <AnimatedBlurView tint="light" intensity={90} pointerEvents="none" style={[{ position: "absolute", inset: 0 }, edgeBlurStyle]} />
          )}
        </Animated.View>
      </View>
    </Pressable>
  );
}