/**
* FlipCard — two faces of one card, and a turn between them.
*
* ```tsx
*
*
* Tap me
*
*
* The other side
*
*
* ```
*
* For content that is genuinely two-sided — a bank card and its security code,
* a term and its definition, a photograph and what it is of. Not for
* progressive disclosure: the back replaces the front rather than extending
* it, so anything the reader needs to compare across the turn belongs in
* [Collapsible](../collapsible) or a second card instead.
*
* ## One box, and both faces in it
*
* The back is laid over the front, so there is one box and the two faces fill
* it. State the height on the root and both faces take it; leave it off and
* the front's own content decides, with the back given whatever that came to.
*
* A back with more in it than the front overflows rather than growing the
* card, and the fix is a height on the root rather than more padding on the
* back — a card whose height changed halfway through the turn would be a card
* that moved everything under it.
*
* ## Both faces are hidden the same way twice
*
* `backfaceVisibility` is the mechanism, and it is not reliable on every
* Android surface — so the face that has turned away is also faded out and
* dropped behind, on the same shared value, at the halfway point. Two
* mechanisms for one job, because the failure of the first is a card that
* shows both faces mirrored through each other and nothing in the tree that
* says why.
*
* ## Under reduce motion
*
* The faces swap with no turn at all. Not a shorter turn — none. The rotation
* is the part that moves, and moving is the part the setting is about; which
* face is showing is the information, and it is kept.
*/
import {
Children,
createContext,
forwardRef,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { StyleSheet, View, type ViewProps } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
cancelAnimation,
interpolate,
runOnJS,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { tv } from 'tailwind-variants';
import { cn } from '../../utils/cn';
/**
* How the card settles on a face.
*
* Clamped, and a little heavier than the library's other springs. A card has
* apparent mass — it is a rectangle the size of your hand — and a turn that
* overshoots and comes back reads as a playing card being flicked rather than
* an object being turned over. The overshoot is also the one place the hidden
* face can appear, since it puts the rotation past 360.
*/
const SPRING = {
damping: 18,
stiffness: 140,
mass: 0.9,
overshootClamping: true,
} as const;
/**
* Distance across the card that counts as a whole turn, as a fraction of its
* own width or height.
*
* Less than the full span: a drag that has crossed two thirds of the card has
* made its point, and requiring the whole width means the last part of every
* deliberate flip is spent travelling to an edge the finger has to reach.
*/
const DRAG_SPAN = 0.66;
/** Velocity, in points per second, that carries a short drag over anyway. */
const FLICK_VELOCITY = 500;
const flipCardVariants = tv({
slots: {
root: 'relative',
face: 'w-full',
},
});
/** Which axis the card turns about. */
export type FlipCardDirection = 'horizontal' | 'vertical';
/** Which way round it turns. */
export type FlipCardRotation = 'normal' | 'reverse';
/** What flips the card. */
export type FlipCardTrigger = 'press' | 'drag' | 'none';
interface FlipCardContextValue {
flipped: boolean;
direction: FlipCardDirection;
rotation: FlipCardRotation;
perspective: number;
progress: SharedValue;
flip: () => void;
}
const FlipCardContext = createContext(null);
function useFlipCardContext(component: string) {
const context = useContext(FlipCardContext);
if (!context) throw new Error(`${component} must be used within a `);
return context;
}
/**
* The card's state, for a face that needs to know which way round it is.
*
* `progress` runs 0 → 1 across the turn and is a shared value, so a face can
* drive its own animation off the same turn rather than starting a second one
* beside it.
*/
export function useFlipCard() {
const { flipped, direction, rotation, progress, flip } = useFlipCardContext('useFlipCard');
return { flipped, direction, rotation, progress, flip };
}
export interface FlipCardProps extends Omit {
/** `FlipCard.Front` and `FlipCard.Back`, in either order. */
children?: ReactNode;
/**
* Which axis the card turns about. `horizontal` turns it left to right about
* its vertical axis; `vertical` turns it top over bottom.
*/
direction?: FlipCardDirection;
/**
* Which way round the turn goes. `reverse` sends it the other way, for a
* pair of cards that should not turn identically.
*/
rotation?: FlipCardRotation;
/**
* Which face is showing, when the caller holds it. Leave unset to let the
* card keep its own, and pair with `trigger="none"` for a card flipped only
* from outside.
*/
flipped?: boolean;
/** Which face an uncontrolled card starts on. */
defaultFlipped?: boolean;
/** Fires whenever the card settles on the other face, however it got there. */
onFlippedChange?: (flipped: boolean) => void;
/**
* What turns the card. `press` is a tap anywhere on it; `drag` turns it with
* the finger and springs to whichever face is nearer on release; `none`
* leaves it to `flipped`.
*/
trigger?: FlipCardTrigger;
/**
* How deep the turn looks, in points. Smaller is more dramatic — the near
* edge swings further out — and below about 400 a full card starts to read
* as a door rather than a card.
*/
perspective?: number;
/** Classes for the card's own box. The front's size is the card's size. */
className?: string;
}
const FlipCardRoot = forwardRef(
(
{
children,
direction = 'horizontal',
rotation = 'normal',
flipped: flippedProp,
defaultFlipped = false,
onFlippedChange,
trigger = 'press',
perspective = 1000,
className,
...props
},
ref
) => {
const [internal, setInternal] = useState(defaultFlipped);
const isControlled = flippedProp !== undefined;
const flipped = isControlled ? flippedProp : internal;
const progress = useSharedValue(defaultFlipped ? 1 : 0);
const startProgress = useSharedValue(0);
const span = useSharedValue(0);
const reduceMotion = useReducedMotion();
const { root, face } = flipCardVariants();
/*
* The settled face is React state and the turn is a shared value, and the
* two are kept in step from here rather than from the gesture: a drag that
* is let go past halfway has already decided, and telling React at the
* moment of release rather than at the end of the spring is what keeps the
* hidden face out of the accessibility tree while it is still turning.
*/
const settle = useCallback(
(next: boolean) => {
if (!isControlled) setInternal(next);
onFlippedChange?.(next);
},
[isControlled, onFlippedChange]
);
useEffect(() => {
progress.value = reduceMotion
? flipped
? 1
: 0
: withSpring(flipped ? 1 : 0, SPRING);
}, [flipped, progress, reduceMotion]);
useEffect(() => () => cancelAnimation(progress), [progress]);
const flip = useCallback(() => settle(!flipped), [flipped, settle]);
const gesture = useMemo(() => {
const tap = Gesture.Tap()
.enabled(trigger === 'press')
.onEnd((_event, success) => {
if (success) runOnJS(flip)();
});
/*
* The span is read from the shared value rather than from a captured
* number, so a card that is measured or resized mid-touch does not leave
* the gesture dividing by the width it had when the finger landed.
*/
const pan = Gesture.Pan()
.enabled(trigger === 'drag')
.onBegin(() => {
startProgress.value = progress.value;
})
.onUpdate((event) => {
const travel = direction === 'horizontal' ? event.translationX : event.translationY;
const reach = Math.max(span.value * DRAG_SPAN, 1);
const next = startProgress.value + travel / reach;
progress.value = Math.min(Math.max(next, 0), 1);
})
.onEnd((event) => {
const velocity = direction === 'horizontal' ? event.velocityX : event.velocityY;
const committed =
progress.value > 0.5 || (progress.value > 0.1 && velocity > FLICK_VELOCITY);
runOnJS(settle)(committed);
});
return Gesture.Exclusive(pan, tap);
}, [direction, flip, progress, settle, span, startProgress, trigger]);
/*
* One turn, read two ways. The front runs 0 → 180 and the back 180 → 360,
* so at any moment exactly one of them has its face toward the reader.
*
* `sign` is not an RTL mirror. Turning a physical object over is a physical
* direction, not a reading direction, and a card that spun the other way
* in an Arabic layout would be a different card rather than the same one
* laid out correctly.
*/
const sign = rotation === 'reverse' ? -1 : 1;
const axis = direction === 'horizontal' ? 'rotateY' : 'rotateX';
const frontStyle = useAnimatedStyle(() => {
const angle = interpolate(progress.value, [0, 1], [0, 180 * sign]);
const showing = progress.value < 0.5;
return {
transform: [{ perspective }, { [axis]: `${angle}deg` } as never],
opacity: showing ? 1 : 0,
zIndex: showing ? 1 : 0,
};
});
const backStyle = useAnimatedStyle(() => {
const angle = interpolate(progress.value, [0, 1], [-180 * sign, 0]);
const showing = progress.value >= 0.5;
return {
transform: [{ perspective }, { [axis]: `${angle}deg` } as never],
opacity: showing ? 1 : 0,
zIndex: showing ? 1 : 0,
};
});
let front: ReactNode = null;
let back: ReactNode = null;
for (const child of Children.toArray(children)) {
if (!isValidElement(child)) continue;
if (child.type === FlipCardFront) front = child;
else if (child.type === FlipCardBack) back = child;
}
const context = useMemo(
() => ({ flipped, direction, rotation, perspective, progress, flip }),
[direction, flip, flipped, perspective, progress, rotation]
);
/*
* The gesture goes on the card itself rather than on a view around it.
*
* A wrapper is an extra box with no width of its own, and a card sized
* `w-full` inside one is asking for a percentage of nothing — which is how
* a two-hundred-point card ended up taller than the screen and hanging off
* the side of it.
*
* The accessibility action rides here for the same reason. A drag is not
* available to a screen reader, and a card that can only be turned by
* dragging is a card some readers can never see the back of.
*/
const card = (
{
span.value =
direction === 'horizontal'
? event.nativeEvent.layout.width
: event.nativeEvent.layout.height;
}}
accessible={trigger !== 'none'}
accessibilityRole={trigger === 'none' ? undefined : 'button'}
accessibilityState={{ expanded: flipped }}
accessibilityActions={
trigger === 'none' ? undefined : [{ name: 'activate', label: 'Flip the card over' }]
}
onAccessibilityAction={trigger === 'none' ? undefined : flip}
{...props}
className={root({ className })}
>
{/* The front is in flow and the back is laid over it, so the card is
the front's size on both axes and the back is handed that box. */}
{front}
{back}
);
return (
{/* A gesture rather than a Pressable, and no press feedback with it.
The card already answers the touch by turning; dipping it first
makes the turn look like it started late. */}
{trigger === 'none' ? card : {card}}
);
}
);
export interface FlipCardFaceProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** The face showing at rest. Its size is the card's size. */
const FlipCardFront = forwardRef(({ className, ...props }, ref) => (
));
/** The face revealed by the turn, laid over the front. */
const FlipCardBack = forwardRef(({ className, ...props }, ref) => (
));
FlipCardRoot.displayName = 'FlipCard';
FlipCardFront.displayName = 'FlipCard.Front';
FlipCardBack.displayName = 'FlipCard.Back';
export const FlipCard = Object.assign(FlipCardRoot, {
Front: FlipCardFront,
Back: FlipCardBack,
});