ZAYX logo
XGitHubDiscordReddit

Card Insight

A drag-to-flip 3D card with swipe paging and spending insights on the back.

Open in Github
Open in ChatGPT
Open in Claude
Open in Cursor

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.

npx degit ManasCodeXart/ZAYX/components/card-insight components/card-insight

Install the needed dependencies

npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler @shopify/react-native-skia

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>

PropTypeDefaultDescription
cardCardDataData rendered on both faces.
isActivebooleanEnables/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) => voidCalled when a horizontal swipe crosses the paging threshold instead of flipping. direction is 'next' | 'prev'.

<CardFront>

PropTypeDefaultDescription
lastFourDigitsstringOnly the last 4 characters are rendered, even if a longer string is passed.
holderNamestring
expirystring
balancenumberAnimates in via AnimatedCounter.
visiblebooleanDrives the staggered fade-in for each section. Owned internally by Card3D.

<CardBack>

PropTypeDefaultDescription
holderNamestring
cvvstring
spentThisWeeknumber
previousWeekSpendnumberPass 0 for a new card with no spending history — the delta section renders a — placeholder instead of a percentage.
dueBillsnumber
subscriptionsnumber
visiblebooleanSame as CardFront — owned internally by Card3D.

<CardInfoField>

PropTypeDefaultDescription
labelstring
align'left' | 'right''left'
labelStyleTextStyle
valuestringMutually exclusive with valueNode — plain text value.
valueStyleTextStyleOnly used with value.
valueNodeReactNodeMutually exclusive with value — pass this for anything that isn't a plain string, e.g. an AnimatedCounter.

Gestures

GestureResult
Drag anywhere on the cardCard 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 thresholdFlip is cancelled, card springs back, and onSwipePage fires — swipe left pages next, swipe right pages previous.
Release without meeting either thresholdCard 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; }

Project Structure

card-insight/
├── components/
│ ├── Card3D.tsx # Root component — dual-axis flip + swipe paging
│ ├── CardFront.tsx # Front face — number, holder, expiry, balance
│ ├── CardBack.tsx # Back face — spending insights
│ ├── CardInfoField.tsx # Label/value field used on both faces
│ └── RippleOverlay.tsx # Skia shader ripple on flip settle
├── constants/
│ ├── card3d.ts # Flip spring, paging thresholds, ripple timing
│ └── types.ts # CardData, CardSwipeDirection, SpendingTrend types
├── hooks/
│ └── useFadeIn.ts # Shared per-section fade-in hook
└── utils/
└── spending.ts # getSpendingTrend utility