Tap to flip. With trigger 'none' it only turns when you change flipped from outside.
Motion
A gentle spring turns the card around its vertical or horizontal axis while the shadow beneath narrows as it goes edge-on. Faces swap exactly at 90° so Android never flickers.
Screen readers
The card is a button whose hint says which side is showing, with an expanded state when flipped. The hidden face is hidden from screen readers.
Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.
src/components/ui/flip-card.tsx206 lines
src/lib/kit.tsx220 lines
Want to read it first? Every file is in Source below.
Every variant above has a complete, runnable example in its Code tab.
src/components/ui/flip-card.tsx
import React, { useEffect, useMemo, useRef, useState } from'react';import { Animated, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from'react-native';import { motion } from'@/lib/kit';export type FlipDirection = 'horizontal' | 'vertical';export type FlipTrigger = 'press' | 'none';export interface FlipCardProps { /** Shown first. Sets the card's size — the back fills the same box. */ front: React.ReactNode; back: React.ReactNode; /** Controlled side: `true` shows the back. */ flipped?: boolean; /** Uncontrolled initial side (defaultfalse). */ defaultFlipped?: boolean; onFlip?: (flipped: boolean) => void; /** Spin around the vertical axis (left/right) or the horizontal one (top/bottom). */ direction?: FlipDirection; /** `press` flips on tap; `none` leaves it to `flipped` (e.g. a button elsewhere). */ trigger?: FlipTrigger; /** Depth of the 3D effect — smaller is more dramatic (default1000). */ perspective?: number; /** Corner radius of both faces (default20). */ radius?: number; /** Soft shadow that narrows as the card turns edge-on (defaulttrue). */ shadow?: boolean; /** * Drive the turn from outside (0 = front, 1 = back) — from`useFlipProgress`, when something else * must follow the flip, like a TiltCard's shadow. `flipped` still sets the side for accessibility. */ progress?: Animated.Value; /** Accessibility label; the hint says which side is showing. */ accessibilityLabel?: string; /** Screen-reader hints (defaults in English). */ labels?: { toBack?: string; toFront?: string }; style?: StyleProp<ViewStyle>;}// How wide the shadow under a flipping card is through the turn: roughly cos θ, always a little// narrower than the card so a flat shadow plate never shows past it, and none at all edge-on.const SQUEEZE = { inputRange: [0, 0.25, 0.45, 0.5, 0.55, 0.75, 1], outputRange: [1, 0.6, 0.12, 0, 0.12, 0.6, 1],};/** * The flip as an animated 0 → 1 value, springing like FlipCard's own, plus `shadowScaleX` — pass * `progress` to the FlipCard and `shadowScaleX` to a TiltCard wrapped round it, so the tilt * shadow narrows as the card turns edge-on instead of showing as a dark box behind it. */exportfunction useFlipProgress(flipped: boolean) {const progress = useRef(new Animated.Value(flipped ? 1 : 0)).current; useEffect(() => { Animated.spring(progress, { toValue: flipped ? 1 : 0, ...motion.gentle, useNativeDriver: true }).start(); }, [flipped, progress]);const shadowScaleX = useMemo(() => progress.interpolate(SQUEEZE), [progress]);return { progress, shadowScaleX };}/** * A card with two faces that flips in 3D on tap — it lifts toward you half-way through and its * shadow narrows as it turns edge-on, like a real card turning over. Controlled or uncontrolled. */exportfunction FlipCard({ front, back, flipped: flippedProp, defaultFlipped = false, onFlip, direction = 'horizontal', trigger = 'press', perspective = 1000, radius = 20, shadow = true, progress, accessibilityLabel, labels, style,}: FlipCardProps) {const controlled = flippedProp !== undefined;const [own, setOwn] = useState(defaultFlipped);const flipped = controlled ? flippedProp : own;const turn = useFlipProgress(flipped).progress;const p = progress ?? turn;const toggle = () => {const next = !flipped; if (!controlled) setOwn(next); onFlip?.(next); };const horizontal = direction === 'horizontal';const axis = horizontal ? 'rotateY' : 'rotateX';const frontTurn = p.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'], });const backTurn = p.interpolate({ inputRange: [0, 1], outputRange: ['180deg', '360deg'], });// Lift toward the viewer half-way through the turn.const lift = p.interpolate({ inputRange: [0, 0.5, 1], outputRange: [1, 1.05, 1], });// Swap faces exactly at 90° — backfaceVisibility alone flickers on some Android versions.const frontOpacity = p.interpolate({ inputRange: [0, 0.499, 0.5, 1], outputRange: [1, 1, 0, 0], });const backOpacity = p.interpolate({ inputRange: [0, 0.499, 0.5, 1], outputRange: [0, 0, 1, 1], });const face = (turn: Animated.AnimatedInterpolation<string>, opacity: Animated.AnimatedInterpolation<number>) => ({ borderRadius: radius, opacity, transform: [{ perspective }, { [axis]: turn } as never, { scale: lift }], }) as Animated.WithAnimatedValue<ViewStyle>;// The shadow squeezes along the turning axis as the card goes edge-on (see SQUEEZE) and fades a little.const squeeze = p.interpolate(SQUEEZE);const body = ( <View> {shadow ? ( <Animated.ViewpointerEvents="none"style={[ styles.plate, { borderRadius: radius, opacity: p.interpolate({ inputRange: [0, 0.5, 1], outputRange: [1, 0.4, 1], }), transform: [{ scale: 0.9 }, horizontal ? { scaleX: squeeze } : { scaleY: squeeze }], }, ]} /> ) : null} {/* Faces live in their own (non-collapsing) layer: iOS intersects sibling layers in 3D, so a turning face next to the flat shadow plate would be sliced by it. */} {/* On Android the wrapper carries the elevation that keeps the faces above the plate; the faces get none, since a face turned in 3D would cast a warped shadow wedge below the card. */} <Viewcollapsable={false} style={{ elevation: shadow ? 23 : 0 }}> {/* The hidden face stays mounted (for the turn) but is hidden from screen readers. */} <Animated.Viewstyle={[styles.face, face(frontTurn, frontOpacity)]}pointerEvents={flipped ? 'none' : 'auto'}accessibilityElementsHidden={flipped}importantForAccessibility={flipped ? 'no-hide-descendants' : 'auto'} > {front} </Animated.View> <Animated.Viewstyle={[styles.face, styles.back, face(backTurn, backOpacity)]}pointerEvents={flipped ? 'auto' : 'none'}accessibilityElementsHidden={!flipped}importantForAccessibility={flipped ? 'auto' : 'no-hide-descendants'} > {back} </Animated.View> </View> </View> ); if (trigger === 'none') return <Viewstyle={style}>{body}</View>;return ( <PressableonPress={toggle}accessibilityRole="button"accessibilityLabel={accessibilityLabel}accessibilityHint={flipped ? (labels?.toFront ?? 'Showing the back. Tap to flip to the front.') : (labels?.toBack ?? 'Tap to flip to the back.')}accessibilityState={{ expanded: flipped }}style={style} > {body} </Pressable> );}const styles = StyleSheet.create({ face: { overflow: 'hidden', backfaceVisibility: 'hidden' }, back: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }, plate: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: '#000', shadowColor: '#000', shadowOpacity: 0.4, shadowRadius: 20, shadowOffset: { width: 0, height: 14 },// High enough that Android's shadow spreads past the inset plate (iOS uses the shadow* props). elevation: 22, },});