Card Insight
A drag-to-flip 3D card with swipe paging and spending insights on the back.
True dual-axis 3D flip
Horizontal drag rotates around the Y axis, vertical drag around the X axis following your finger in real time, not a flat left-right flip.
Non-conflicting swipe paging
A mostly-horizontal drag that crosses a distance or velocity threshold cancels the flip and pages to the next or previous card instead resolved by the same gesture recognizer so the two never fight.
Skia ripple on settle
A shader-driven ripple plays across the card the moment both rotation axes finish settling never mid-drag, never before the flip is done.
Live spending insights
The back face shows spent-this-week, a week-over-week % delta with a directional arrow, due bills, and subscriptions all animated via AnimatedCounter. Pass previousWeekSpend: 0 for a graceful new-card placeholder instead of dividing by zero.
Installation
Copy the command directly into your project.
Install the needed dependencies
Use in your app
Built to match the preview exactly — for the complete implementation, check the repo on GitHub.
import { useState } from 'react'
import { StyleSheet, View } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Card3D from '../../components/card-insight/components/Card3D'
import { CardData, CardSwipeDirection } from '../../components/card-insight/constants/types'
const CARDS: readonly CardData[] = [
{
id: '553455',
lastFourDigits: '6754',
holderName: 'Manas Sharma',
expiry: '20/28',
balance: 8424,
cvv: '156',
spentThisWeek: 1000,
previousWeekSpend: 789,
dueBills: 4500,
subscriptions: 250,
},
]
const Index = () => {
const [activeIndex, setActiveIndex] = useState(0)
const handleSwipePage = (direction: CardSwipeDirection) => {
setActiveIndex((prev) => {
if (direction === 'next') return Math.min(prev + 1, CARDS.length - 1)
return Math.max(prev - 1, 0)
})
}
return (
<GestureHandlerRootView style={styles.root}>
<View style={styles.container}>
<View style={styles.cardWrapper}>
<Card3D card={CARDS[activeIndex]} isActive onSwipePage={handleSwipePage} />
</View>
</View>
</GestureHandlerRootView>
)
}
export default Index
const styles = StyleSheet.create({
root: {
flex: 1,
},
container: {
flex: 1,
backgroundColor: 'black',
justifyContent: 'center',
},
cardWrapper: {
alignSelf: 'center',
},
})API
<Card3D>
| Prop | Type | Default | Description |
|---|---|---|---|
| card | CardData | — | Data rendered on both faces. |
| isActive | boolean | — | Enables/disables the gesture. Pass false for any card that isn't the current page in a multi-card layout so only one card can be dragged at a time. |
| onSwipePage | (direction: CardSwipeDirection) => void | — | Called when a horizontal swipe crosses the paging threshold instead of flipping. direction is 'next' | 'prev'. |
<CardFront>
| Prop | Type | Default | Description |
|---|---|---|---|
| lastFourDigits | string | — | Only the last 4 characters are rendered, even if a longer string is passed. |
| holderName | string | — | — |
| expiry | string | — | — |
| balance | number | — | Animates in via AnimatedCounter. |
| visible | boolean | — | Drives the staggered fade-in for each section. Owned internally by Card3D. |
<CardBack>
| Prop | Type | Default | Description |
|---|---|---|---|
| holderName | string | — | — |
| cvv | string | — | — |
| spentThisWeek | number | — | — |
| previousWeekSpend | number | — | Pass 0 for a new card with no spending history — the delta section renders a — placeholder instead of a percentage. |
| dueBills | number | — | — |
| subscriptions | number | — | — |
| visible | boolean | — | Same as CardFront — owned internally by Card3D. |
<CardInfoField>
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | — |
| align | 'left' | 'right' | 'left' | — |
| labelStyle | TextStyle | — | — |
| value | string | — | Mutually exclusive with valueNode — plain text value. |
| valueStyle | TextStyle | — | Only used with value. |
| valueNode | ReactNode | — | Mutually exclusive with value — pass this for anything that isn't a plain string, e.g. an AnimatedCounter. |
Gestures
| Gesture | Result |
|---|---|
| Drag anywhere on the card | Card tilts in real time — horizontal movement rotates around the Y axis, vertical movement rotates around the X axis. |
| Release after crossing the flip commit threshold (~45° on Y axis) | Card snaps to the opposite face; once both axes finish settling, the Skia ripple plays. |
| Release before commit threshold with a mostly-horizontal drag past distance or velocity threshold | Flip is cancelled, card springs back, and onSwipePage fires — swipe left pages next, swipe right pages previous. |
| Release without meeting either threshold | Card springs back to whichever face was already showing; the ripple still plays once settled. |
Flip spring, page thresholds, and ripple timing are tuned via constants in constants/card3d.ts rather than exposed as props — edit them directly if you want a different feel.
getSpendingTrend
Utility used internally by CardBack to compute the week-over-week trend. Exported separately if you want to reuse the same trend logic elsewhere. When previousWeekSpend is 0, it short-circuits to hasComparison: false before any division.
Types
interface CardData {
readonly id: string;
readonly lastFourDigits: string;
readonly holderName: string;
readonly expiry: string;
readonly balance: number;
readonly cvv: string;
readonly spentThisWeek: number;
readonly previousWeekSpend: number;
readonly dueBills: number;
readonly subscriptions: number;
}
type CardSwipeDirection = 'next' | 'prev';
type CardFace = 'front' | 'back';
interface SpendingTrend {
readonly percentChange: number;
readonly isIncrease: boolean;
readonly hasComparison: boolean;
}