Build a Dating App with Gesture Animations
Build a swipeable dating app with spring physics and animated UI in React Native.
Introduction
30 Second Summary
Every dating app you've ever used has that satisfying card swipe that follows your finger, tilts slightly, and snaps away with just the right amount of bounce. That buttery-smooth feeling comes from animations running directly on the device's native thread at 60 frames per second.
In this project, you'll build a dating app called Spark where you swipe through profile cards with physics-based animations. You'll use React Native Reanimated to make every gesture feel native-smooth, all running live on your own phone.
What You'll Build
You'll drag a profile card with your finger, watch it tilt and reveal a green LIKE overlay as you pull right, then release and see it fly off-screen with spring physics while the next card scales up from behind.
By the end of this project, you'll have:
- A swipeable card deck where cards follow your finger, tilt as you drag, and fly off-screen with spring physics when you commit to a swipe.
- A matches screen where your liked profiles cascade in with staggered fade-in animations each time you open the tab.
- A polished dark-themed app with tab navigation between Discover and Matches screens, powered by Expo Router and styled entirely with NativeWind utility classes.
- Secret Mission: Add an undo button that springs the last swiped card back into the deck with a reverse animation.
Are there any prerequisites?
You should have a basic understanding of React and JavaScript.
Familiarity with how React Native components work (Views, Text, StyleSheet) is helpful but not required.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters to you.
Set Up Your Expo Project with NativeWind
To build a dating app with smooth gesture animations, you first need a React Native project running on your phone. Expo gives you the fastest path from zero to live app on a physical device.
In this step, you will create an Expo project, install NativeWind (which brings Tailwind CSS utility classes to React Native), and confirm everything works on your phone.
In this step, get ready to:
- Create the Expo project with SDK 57 and verify it runs on your phone.
- Install and configure NativeWind for Tailwind-style mobile styling.
- Confirm NativeWind works on-device with a dark-themed screen.
Create the Expo project and verify it runs
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Spark project by running this command:
npx create-expo-app@latest spark --template default@sdk-57
What does this command do?
npx create-expo-app@latest scaffolds a new React Native project with Expo Router, TypeScript, and multi-platform support pre-configured.
The --template default@sdk-57 flag is important. Without it, the CLI creates an older SDK 54 project during the SDK 57 transition period.
- Move into your new project folder by running:
cd spark
- Start the Expo development server by running:
npx expo start
You should see a QR code printed in your terminal along with some server information.
How do I open this on my phone?
Open the Camera app on your iPhone and point it at the QR code in the terminal. A banner appears at the top of your screen. Tap it to open the app in Expo Go.
- Open the Camera app on your iPhone.
- Point it at the QR code in your terminal.
- Tap the banner that appears to open the app in Expo Go.
You should see the default Expo template screen on your phone. It has a tabbed interface with example content.
App not loading on your phone?
- Make sure your phone and computer are on the same Wi-Fi network.
- If the QR code does not scan, try pressing s in the terminal to switch to Expo Go mode, then scan again.
- help me connect my phone to the Expo dev server
- Return to your terminal and press Ctrl+C to stop the development server.
Install NativeWind and configure the build tools
NativeWind lets you style React Native components with Tailwind CSS utility classes like bg-black and text-white. It compiles these classes at build time so they run natively.
- Install the NativeWind packages by running these two commands:
npm install nativewind react-native-safe-area-context
npm install --dev tailwindcss@^3.4.17 babel-preset-expo
What are these packages?
- nativewind is the core library that bridges Tailwind CSS to React Native.
- react-native-safe-area-context handles safe area insets (the space around notches and home indicators).
- tailwindcss (pinned to version 3.x) processes your utility classes at build time.
- babel-preset-expo ensures Babel is configured for NativeWind's JSX transform.
Now you need to create the config files that wire NativeWind into the build pipeline.
- Create a global.css file in your project root by running:
touch global.css
- Open global.css in VS Code and paste the following content:
@tailwind base;
@tailwind components;
@tailwind utilities;
- Create a nativewind-env.d.ts file in your project root and paste this single line:
/// <reference types="nativewind/types" />
- Confirm the new files exist by running:
ls global.css nativewind-env.d.ts
You should see both filenames printed with no errors.
- Create a tailwind.config.js file in your project root and paste the following:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./components/**/*.{js,jsx,ts,tsx}",
"./context/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
};
What does this config do?
The content array tells Tailwind which files to scan for class names. Any .tsx file inside app/, components/, or context/ will have its classes compiled into native styles.
The nativewind/preset preset adapts Tailwind's default configuration for React Native constraints.
- Replace the contents of babel.config.js with the following:
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
- Replace the contents of metro.config.js with the following:
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css" });
What do these build configs do?
- babel.config.js adds two presets. The jsxImportSource: "nativewind" option tells Babel to process className props on React Native components. The nativewind/babel preset handles the style compilation.
- metro.config.js wraps the Metro bundler with withNativeWind, which injects the CSS processing step into the bundle pipeline using your global.css as the entry point.
Replace the default app with a dark-themed screen
The default template created a src/ directory with example screens and components. You will replace it with a simpler structure that uses app/ at the project root.
- Delete the default template's src folder by running:
rm -rf src
- Create a fresh app directory by running:
mkdir app
- Create a file called app/_layout.tsx and paste the following:
import "../global.css";
import { Slot } from "expo-router";
export default function RootLayout() {
return <Slot />;
}
What does this layout do?
The import "../global.css" line activates NativeWind's stylesheet for the entire app. The Slot component renders whatever route matches the current URL. This is a minimal layout that lets you see your first screen immediately.
- Create a file called app/index.tsx and paste the following:
import { View, Text } from "react-native";
export default function DiscoverScreen() {
return (
<View className="flex-1 bg-[#0f0f1a] items-center justify-center">
<Text className="text-white text-2xl font-bold mt-12 mb-4">Spark</Text>
</View>
);
}
What does this screen do?
This is your app's home screen. The className props use Tailwind utility classes: bg-[#0f0f1a] sets a dark background, flex-1 makes it fill the screen, and text-white colors the text. If NativeWind is working, you will see these styles applied on your phone.
- Save both files.
Before you run the app, what do you expect to see on your phone?
- Start the development server with a cleared cache by running:
npx expo start -c
- Scan the QR code with your iPhone camera and tap the banner to open in Expo Go.
You should see a completely dark screen with the word "Spark" in white, bold text centered on the page. The dark background confirms NativeWind is compiling your Tailwind classes into native styles.
Seeing a white screen or unstyled text?
- Make sure you deleted the src folder. If src/app still exists, Expo Router uses it instead of your new app/ folder.
- Check that global.css is imported in app/_layout.tsx. Without this import, NativeWind styles are never loaded.
- Confirm you used the -c flag with npx expo start to clear the bundler cache. Config file changes require a fresh cache.
- help me debug why NativeWind styles are not showing in my Expo app
✔️ Awesome, I've got everything!
Great. Double check that you have saved all five config files before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these files matches what you pasted:
- global.css (3 lines with Tailwind directives).
- nativewind-env.d.ts (single reference line).
- tailwind.config.js (content paths, nativewind preset, empty theme and plugins).
- babel.config.js (two presets with jsxImportSource).
- metro.config.js (getDefaultConfig wrapped with withNativeWind).
Also confirm app/_layout.tsx imports ../global.css and exports a Slot, and app/index.tsx renders a View with className="flex-1 bg-[#0f0f1a] items-center justify-center".
Your project is scaffolded and NativeWind is confirmed working on your device. Next up, you will build the first profile card that users swipe through.
Build a Static Profile Card
Your environment is wired up and styling works on your phone. Now you need something to actually look at.
Before you add any gestures or animations, you need a single beautiful card on screen. This gives you an immediate visual win and a concrete component to attach swipe behavior to in the next step.
In this step, get ready to:
- Create a mock data file with dating profiles.
- Build a ProfileCard component with a photo background, gradient overlay, and text.
- Render the top card on the home screen and confirm it displays on-device.
Create a mock data file with dating profiles
Every dating app needs profiles to show. You'll create a typed data file with six mock profiles, each using a free placeholder image from picsum.photos.
What is picsum.photos?
It's a free image placeholder service. Each seed URL (like picsum.photos/seed/alex/400/600) returns the same consistent photo every time. No API key needed.
- In the VS Code Explorer sidebar, create a new folder called data inside your spark project folder.
- Inside data, create a new file called profiles.ts and paste in the following code:
export interface Profile {
id: string;
name: string;
age: number;
bio: string;
image: string;
}
export const profiles: Profile[] = [
{
id: "1",
name: "Alex",
age: 28,
bio: "Coffee enthusiast. Dog lover. Weekend hiker.",
image: "https://picsum.photos/seed/alex/400/600",
},
{
id: "2",
name: "Jordan",
age: 25,
bio: "Bookworm by day, stargazer by night.",
image: "https://picsum.photos/seed/jordan/400/600",
},
];
What does this code do?
The Profile interface defines the shape every profile must follow: an ID, name, age, bio, and image URL.
The profiles array holds the actual data. Each image URL uses a seed string so the same photo loads every time.
- Now add the remaining four profiles below the Jordan entry, before the closing ];:
{
id: "3",
name: "Sam",
age: 30,
bio: "Foodie. Travel addict. Amateur photographer.",
image: "https://picsum.photos/seed/sam/400/600",
},
{
id: "4",
name: "Riley",
age: 27,
bio: "Yoga instructor. Plant parent. Sunset chaser.",
image: "https://picsum.photos/seed/riley/400/600",
},
{
id: "5",
name: "Casey",
age: 26,
bio: "Software dev. Board game nerd. Cat person.",
image: "https://picsum.photos/seed/casey/400/600",
},
{
id: "6",
name: "Morgan",
age: 29,
bio: "Music producer. Vinyl collector. Night owl.",
image: "https://picsum.photos/seed/morgan/400/600",
},
Why six profiles?
Six gives you enough cards to swipe through and test the deck behavior without making the data file unwieldy. Each profile has a unique seed so the images are distinct.
- Save data/profiles.ts.
✔️ Awesome, I've got everything!
Great. Make sure the closing ]; sits after the Morgan entry at the bottom of the array.
ⓧ I'd like to double check the full code
Your data/profiles.ts should contain these sections in order:
- The Profile interface with five fields (id, name, age, bio, image).
- The profiles array with six entries: Alex (28), Jordan (25), Sam (30), Riley (27), Casey (26), Morgan (29).
- Each entry has a picsum.photos/seed/<name>/400/600 image URL.
Scroll up to the two code blocks above and confirm your file matches both chunks combined.
Build the ProfileCard component
Each profile card needs a full-bleed photo with text overlaid at the bottom. A semi-transparent dark overlay behind the text ensures readability regardless of the photo content.
- Create a new folder called components inside your spark project folder.
- Inside components, create a new file called ProfileCard.tsx and paste in the following code:
import { View, Text, Image } from "react-native";
import { Profile } from "../data/profiles";
// Define the props this component accepts
interface ProfileCardProps {
profile: Profile;
}
export function ProfileCard({ profile }: ProfileCardProps) {
return (
<View className="w-full h-full rounded-3xl overflow-hidden bg-[#1a1a2e]">
{/* Full-bleed background photo */}
<Image
source={{ uri: profile.image }}
className="w-full h-full absolute"
resizeMode="cover"
/>
{/* Semi-transparent overlay with profile text */}
<View className="absolute bottom-0 left-0 right-0 p-6 bg-black/50">
<Text className="text-white text-3xl font-bold">
{profile.name}, {profile.age}
</Text>
<Text className="text-gray-200 text-base mt-1">{profile.bio}</Text>
</View>
</View>
);
}
What does this code do?
The card fills its parent container with w-full h-full and clips the photo to rounded corners with rounded-3xl overflow-hidden.
The Image is positioned absolute so it sits behind everything else, filling the card edge to edge.
The bottom overlay uses bg-black/50 for a 50% opacity black background. This keeps the name and bio readable over any photo.
- Save components/ProfileCard.tsx.
Seeing red squiggly lines in the imports?
If VS Code shows an error on ../data/profiles, make sure you saved data/profiles.ts from the previous substep first.
Help me fix import errors in my React Native project.
Render the top card on the home screen
You have data and a card component. Now you'll wire them together on the home screen so you can see the first profile on your phone.
- Create a new file called SwipeDeck.tsx inside the components folder and paste in the following code:
import { useState } from "react";
import { View, Text } from "react-native";
import { profiles } from "../data/profiles";
import { ProfileCard } from "./ProfileCard";
// A deck that shows the top profile card
export function SwipeDeck() {
const [currentIndex, setCurrentIndex] = useState(0);
// Show a message when all profiles are exhausted
if (currentIndex >= profiles.length) {
return (
<View className="flex-1 items-center justify-center">
<Text className="text-gray-400 text-lg">No more profiles!</Text>
</View>
);
}
return (
<View className="flex-1 w-full items-center justify-center px-4">
<View className="w-full h-[70%] rounded-3xl">
<ProfileCard profile={profiles[currentIndex]} />
</View>
</View>
);
}
What does this code do?
The SwipeDeck component manages which profile is currently showing using currentIndex state.
For now it just renders the first card statically. In the next step, you'll add pan gestures and spring animations to make the cards swipeable.
- Save components/SwipeDeck.tsx.
- Open app/index.tsx and replace its entire contents with this code:
import { View, Text } from "react-native";
import { SwipeDeck } from "../components/SwipeDeck";
// The main Discover screen showing the card deck
export default function DiscoverScreen() {
return (
<View className="flex-1 bg-[#0f0f1a] items-center justify-center">
<Text className="text-white text-2xl font-bold mt-12 mb-4">Spark</Text>
<SwipeDeck />
</View>
);
}
What does this code do?
This replaces the default Expo template screen with your Discover screen. It renders the app title and your SwipeDeck component on a dark background (#0f0f1a).
- Save app/index.tsx.
Before you check your phone, what do you expect to see? Think about what the card layout should look like given the styles you just wrote.
- Look at your phone. The app should auto-refresh via Expo Go.
You should see a dark screen with "Spark" at the top and a large rounded card showing a photo with "Alex, 28" and the bio text overlaid at the bottom.
Don't see the card?
Make sure npx expo start is still running in your terminal. If the app shows an error screen, read the red text at the top for the file and line number causing the issue.
If the card appears but the photo is missing, check your internet connection. The images load from picsum.photos and require network access.
Help me debug why my profile card isn't showing up in my Expo app.
✔️ Awesome, I've got everything!
Great. Double check you've saved all four files.
ⓧ I'd like to double check the full code
Confirm each file you built in this step matches the code blocks above:
- data/profiles.ts (the interface and 6-profile array from the first substep).
- components/ProfileCard.tsx (the card component from the second substep).
- components/SwipeDeck.tsx (the deck wrapper from the third substep).
- app/index.tsx (the Discover screen from the third substep).
Scroll up to each code block and confirm your files match line for line.
You've got a polished profile card rendering on your phone. Next up, you'll bring it to life with swipe gestures, spring physics, and rotation that follows your finger in real-time.
Add Swipe Gestures with Reanimated
Your profile card looks great, but a static image is not a dating app. The core interaction is the swipe: drag right to like, drag left to pass.
Building this gesture requires a key shift in thinking. Normal React state updates trigger re-renders on the JavaScript thread, which causes jank. React Native Reanimated runs animations directly on the native UI thread, keeping everything at 60fps even while JavaScript is busy.
In this step, get ready to:
- Install gesture and animation libraries, then wrap the app in the required providers.
- Build a swipeable card with rotation, vertical friction, and spring physics.
- Render stacked cards so the next profile peeks out as you swipe.
Install packages and set up gesture infrastructure
Before any gesture code works, you need the animation engine and a shared state context for tracking matches across screens.
- Install the gesture and animation libraries plus an icon library for the tab bar by running this command:
npx expo install react-native-reanimated react-native-gesture-handler react-native-svg lucide-react-native
- Stop your dev server with Ctrl+C if it is still running, then restart it after installation completes by running:
npx expo start
Why so many packages?
react-native-reanimated runs animations on the native UI thread. react-native-gesture-handler provides pan, pinch, and tap recognizers. react-native-svg is required by lucide-react-native, which supplies lightweight icons for the tab bar.
Now you need a way to remember which profiles the user liked. A React context lets you share that state between the Discover screen and the Matches screen you will build later.
- Create a new folder called context in your project root.
- Inside it, create a file called MatchesContext.tsx and paste the following imports and type definitions:
import { createContext, useContext, useState, ReactNode } from "react";
import { Profile } from "../data/profiles";
// Define the shape of our context
interface MatchesContextType {
matches: Profile[];
addMatch: (profile: Profile) => void;
removeLastMatch: () => Profile | undefined;
}
// Create the context with safe defaults
const MatchesContext = createContext<MatchesContextType>({
matches: [],
addMatch: () => {},
removeLastMatch: () => undefined,
});
What does this code do?
MatchesContextType defines a contract: any component using this context can read the matches array, add a new match, or remove the last one.
createContext initializes the context with empty defaults so TypeScript does not complain when components read it before a provider mounts.
- Still in context/MatchesContext.tsx, paste the provider component and hook below the code you just added:
export function MatchesProvider({ children }: { children: ReactNode }) {
const [matches, setMatches] = useState<Profile[]>([]);
// Add a profile to the matches list
const addMatch = (profile: Profile) => {
setMatches((prev) => [...prev, profile]);
};
// Remove and return the last matched profile (for undo)
const removeLastMatch = (): Profile | undefined => {
let removed: Profile | undefined;
setMatches((prev) => {
if (prev.length === 0) return prev;
removed = prev[prev.length - 1];
return prev.slice(0, -1);
});
return removed;
};
return (
<MatchesContext.Provider value={{ matches, addMatch, removeLastMatch }}>
{children}
</MatchesContext.Provider>
);
}
export const useMatches = () => useContext(MatchesContext);
What does this code do?
MatchesProvider wraps your app and holds the matches array in state. addMatch appends a liked profile. removeLastMatch pops the most recent match off (useful later for undo).
useMatches is a convenience hook so any component can access the context with one line.
- Save context/MatchesContext.tsx.
Next, you need a root layout that wraps the entire app in the gesture handler and the matches provider. This layout also sets up a two-tab navigation bar.
- Open app/_layout.tsx. If it already exists, replace its entire contents. If not, create it. Paste the following:
import "../global.css";
import { Tabs } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Heart, Flame } from "lucide-react-native";
import { MatchesProvider } from "../context/MatchesContext";
export default function RootLayout() {
return (
// GestureHandlerRootView must wrap everything for gestures to work
<GestureHandlerRootView style={{ flex: 1 }}>
<MatchesProvider>
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: "#1a1a2e",
borderTopColor: "#2a2a4a",
},
tabBarActiveTintColor: "#e94057",
tabBarInactiveTintColor: "#6b7280",
}}
>
What does this code do?
GestureHandlerRootView is required at the root of your app for any gesture to register. Without it, pan and swipe gestures silently fail.
The Tabs component from Expo Router creates file-based tab navigation. The screenOptions configure a dark tab bar that matches the app theme.
- Still in app/_layout.tsx, paste the tab screen definitions directly below the opening > of <Tabs>:
<Tabs.Screen
name="index"
options={{
title: "Discover",
tabBarIcon: ({ color, size }) => (
<Flame color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="matches"
options={{
title: "Matches",
tabBarIcon: ({ color, size }) => (
<Heart color={color} size={size} />
),
}}
/>
</Tabs>
</MatchesProvider>
</GestureHandlerRootView>
);
}
What does this code do?
Each Tabs.Screen maps a file in the app/ folder to a tab. name="index" points to app/index.tsx (Discover), and name="matches" will point to app/matches.tsx once you create it in a later step.
The Flame and Heart icons from lucide-react-native serve as the tab bar icons.
- Save app/_layout.tsx.
- Check your phone. You should see a dark tab bar at the bottom with a Discover tab (flame icon) and a Matches tab (heart icon).
Don't see the tab bar?
Make sure you restarted the dev server after installing the new packages. Press Ctrl+C and run npx expo start again.
If you see an error about a missing matches route, that is expected for now. The Matches screen file does not exist yet. help me fix the missing matches route error
Create the swipeable card with pan gestures
Now for the main event. You will build a SwipeDeck component that uses Reanimated shared values to track the card position and a pan gesture to drive them.
- Create components/SwipeDeck.tsx and paste the imports and constants:
import { useState, useRef } from "react";
import { View, Text, Dimensions } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolate,
runOnJS,
Extrapolation,
} from "react-native-reanimated";
import { profiles, Profile } from "../data/profiles";
import { ProfileCard } from "./ProfileCard";
import { useMatches } from "../context/MatchesContext";
// Screen width determines how far a card must travel to count as a swipe
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.3;
What is a shared value?
useSharedValue creates a value that lives on the native UI thread. Unlike React state, updating a shared value does NOT trigger a re-render. This is how Reanimated achieves 60fps animations.
SWIPE_THRESHOLD is 30% of the screen width. If the user drags the card past this distance, the swipe counts. Otherwise the card springs back.
- Below the constants, paste the component function with its state and swipe-completion handler:
export function SwipeDeck() {
const [currentIndex, setCurrentIndex] = useState(0);
const { addMatch } = useMatches();
// Shared values drive the card position on the UI thread
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
// Called from the UI thread via runOnJS after a successful swipe
const handleSwipeComplete = (direction: "left" | "right") => {
if (direction === "right" && currentIndex < profiles.length) {
addMatch(profiles[currentIndex]);
}
setCurrentIndex((prev) => prev + 1);
};
What does this code do?
currentIndex tracks which profile card is on top. translateX and translateY store the card's drag offset as shared values so the animation runs on the native thread.
handleSwipeComplete is a JavaScript function that adds the profile to matches (if swiped right) and advances to the next card. It runs on the JS thread, which is why it must be called via runOnJS from the gesture worklet.
- Next, paste the pan gesture definition. This is the heart of the swipe interaction:
const panGesture = Gesture.Pan()
.onUpdate((event) => {
// Move card with finger, dampen vertical to keep it mostly horizontal
translateX.value = event.translationX;
translateY.value = event.translationY * 0.2;
})
.onEnd((event) => {
if (Math.abs(event.translationX) > SWIPE_THRESHOLD) {
// Past threshold: fly card off-screen with spring physics
const direction = event.translationX > 0 ? "right" : "left";
translateX.value = withSpring(
direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5,
{ stiffness: 200, damping: 20, mass: 0.5 },
() => {
runOnJS(handleSwipeComplete)(direction);
translateX.value = 0;
translateY.value = 0;
}
);
translateY.value = withSpring(0);
} else {
// Below threshold: snap back to center
translateX.value = withSpring(0, { stiffness: 300, damping: 30 });
translateY.value = withSpring(0, { stiffness: 300, damping: 30 });
}
});
What does this code do?
onUpdate fires every frame the user drags. The * 0.2 multiplier on vertical translation creates friction so the card stays mostly horizontal.
onEnd fires when the user lifts their finger. If they dragged past 30% of screen width, the card animates off-screen with withSpring (spring physics give it a natural feel). The completion callback resets the position and advances the index.
If the drag was too short, the card springs back to center with higher stiffness for a snappy return.
- Now paste the animated style that applies rotation based on drag distance:
const cardStyle = useAnimatedStyle(() => {
// Rotate the card proportionally as user drags left/right
const rotation = interpolate(
translateX.value,
[-SCREEN_WIDTH, 0, SCREEN_WIDTH],
[-15, 0, 15],
Extrapolation.CLAMP
);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ rotate: `${rotation}deg` },
],
};
});
What is interpolation?
interpolate maps one range of values to another. Here it maps the horizontal drag distance (from negative screen width to positive screen width) to a rotation of -15 to +15 degrees.
Extrapolation.CLAMP prevents the rotation from exceeding the bounds, so even if the card overshoots it never spins wildly.
- Finally, paste the JSX return that renders the card with gesture detection:
if (currentIndex >= profiles.length) {
return (
<View className="flex-1 items-center justify-center">
<Text className="text-gray-400 text-lg">No more profiles!</Text>
</View>
);
}
return (
<View className="flex-1 w-full items-center justify-center px-4">
<GestureDetector gesture={panGesture}>
<Animated.View
style={[cardStyle]}
className="w-full h-[70%] rounded-3xl"
>
<ProfileCard profile={profiles[currentIndex]} />
</Animated.View>
</GestureDetector>
</View>
);
}
What does this code do?
The empty-state check returns a message when all profiles have been swiped. GestureDetector attaches the pan gesture to the Animated.View wrapper.
The style={[cardStyle]} prop applies the animated transform (position + rotation) every frame. Because shared values update on the native thread, this happens at 60fps without waiting for JavaScript.
- Save components/SwipeDeck.tsx.
- Check your phone. Try dragging the profile card with your finger. It should follow your touch, tilt as you drag, and spring back to center when you release before the threshold.
Card not responding to drag?
Make sure GestureHandlerRootView wraps everything in app/_layout.tsx. Without it, gestures silently fail.
If you see a red error screen, restart the dev server. Reanimated and Gesture Handler require a full reload after first install.
help me debug why my swipe gesture is not working
Render stacked cards for depth
Right now, swiping a card away reveals nothing underneath. Real dating apps show the next card behind the current one, creating a sense of a deck.
- In components/SwipeDeck.tsx, find the cardStyle definition and locate where it ends (the closing });).
- Directly below it, paste the next-card scale animation:
const nextCardStyle = useAnimatedStyle(() => {
// Scale up the next card as the current card is dragged away
const scale = interpolate(
Math.abs(translateX.value),
[0, SWIPE_THRESHOLD],
[0.95, 1],
Extrapolation.CLAMP
);
return {
transform: [{ scale }],
};
});
What does this code do?
The next card starts at 95% scale and grows to 100% as the top card is dragged away. This creates a subtle depth effect that makes the deck feel physical.
- Now find the return statement at the bottom of the component. Replace the entire return block with this updated version that renders the next card behind the current one:
return (
<View className="flex-1 w-full items-center justify-center px-4">
{currentIndex + 1 < profiles.length && (
<Animated.View
style={[nextCardStyle]}
className="absolute w-full h-[70%] rounded-3xl"
>
<ProfileCard profile={profiles[currentIndex + 1]} />
</Animated.View>
)}
<GestureDetector gesture={panGesture}>
<Animated.View
style={[cardStyle]}
className="w-full h-[70%] rounded-3xl"
>
<ProfileCard profile={profiles[currentIndex]} />
</Animated.View>
</GestureDetector>
</View>
);
}
What does this code do?
The next card renders BEFORE the current card in the JSX. In React Native, later elements render on top. So the draggable card sits visually in front of the peeking next card.
The absolute positioning on the next card places it in the same spot. As you drag the top card away, the next card scales up from 95% to 100%, creating the illusion of a stack.
- Save components/SwipeDeck.tsx.
Before you test, what do you think will happen when you swipe a card past the threshold?
- Swipe the card to the right past the threshold on your phone.
The card flies off-screen with spring physics, and the next profile slides into place at full scale. Swipe through a few profiles to see the deck in action.
Card disappears but nothing appears behind it?
Check that the next-card Animated.View appears BEFORE the GestureDetector in your JSX. If it is after, it renders on top and intercepts the gesture.
Also verify that currentIndex + 1 < profiles.length is the condition, not currentIndex < profiles.length. The latter would try to render a profile that does not exist.
help me fix my card stacking in SwipeDeck
✔️ Awesome, I've got everything!
Great. Double check you have saved both components/SwipeDeck.tsx and app/_layout.tsx.
ⓧ I'd like to double check the full code
Here are the files you created or modified in this step. Scroll up and confirm each section is present, in order.
context/MatchesContext.tsx should contain:
- Imports from react and ../data/profiles.
- MatchesContextType interface with matches, addMatch, removeLastMatch.
- createContext call with defaults.
- MatchesProvider function with state and both helper functions.
- useMatches hook export.
app/_layout.tsx should contain:
- Imports for global.css, Tabs, GestureHandlerRootView, icons, and MatchesProvider.
- RootLayout wrapping in GestureHandlerRootView then MatchesProvider then Tabs.
- Two Tabs.Screen entries: index (Discover with Flame icon) and matches (Matches with Heart icon).
components/SwipeDeck.tsx should contain:
- Imports from react, react-native, gesture-handler, reanimated, profiles, ProfileCard, and MatchesContext.
- SCREEN_WIDTH and SWIPE_THRESHOLD constants.
- SwipeDeck function with currentIndex state, shared values, handleSwipeComplete.
- panGesture with onUpdate (drag + vertical friction) and onEnd (fly-off or spring-back).
- cardStyle with rotation interpolation.
- nextCardStyle with scale interpolation.
- JSX return with stacked next-card Animated.View (absolute) above GestureDetector wrapping the current card.
Your card deck is alive. Drag right, drag left, feel the spring physics, watch the next card grow into place. Next up, you will add LIKE and NOPE overlays that fade in as you drag, giving the user clear visual feedback before they commit to a swipe.
Add LIKE/NOPE Overlays That Respond to Drag
Your card now follows your finger, rotates, and snaps back with spring physics. But there is a problem: as you drag, you have no idea whether you are about to like or reject someone.
In this step, you will add LIKE and NOPE overlay labels that fade in proportionally as you drag. The overlays use interpolation to map the card's horizontal position directly to opacity, giving clear visual intent before you commit to a swipe.
In this step, get ready to:
- Create animated styles that map drag distance to overlay opacity.
- Render green LIKE and red NOPE labels on the card that respond to your finger in real-time.
- Confirm that swiped-right profiles are being stored as matches.
Add animated overlay styles
The overlays should be invisible when the card is centered and fully opaque when the card reaches the swipe threshold. You will use interpolate to create this proportional mapping between drag distance and opacity.
- In SwipeDeck.tsx, find the cardStyle animated style. Add the following two new animated styles directly below it (before nextCardStyle):
const likeOverlayStyle = useAnimatedStyle(() => ({
opacity: interpolate(
translateX.value,
[0, SWIPE_THRESHOLD],
[0, 1],
Extrapolation.CLAMP
),
}));
const nopeOverlayStyle = useAnimatedStyle(() => ({
opacity: interpolate(
translateX.value,
[-SWIPE_THRESHOLD, 0],
[1, 0],
Extrapolation.CLAMP
),
}));
What does this code do?
Each style uses interpolate to map the card's horizontal position to an opacity value between 0 and 1.
- likeOverlayStyle: opacity goes from 0 (card centered) to 1 (card dragged right to the threshold). Dragging left keeps it at 0.
- nopeOverlayStyle: opacity goes from 1 (card dragged left to the threshold) to 0 (card centered). Dragging right keeps it at 0.
- Extrapolation.CLAMP prevents opacity from exceeding 1 or dropping below 0 if the user drags past the threshold.
- Save SwipeDeck.tsx. These styles will drive the overlay elements you add next.
Render the LIKE and NOPE overlays on the card
Now you need actual elements on the card for those styles to control. Each overlay is an absolutely-positioned label that sits on top of the profile photo.
- In the same file, scroll down to the return statement. Inside the GestureDetector, find this line:
<ProfileCard profile={profiles[currentIndex]} />
- Add the following two overlay elements directly below that line, still inside the same Animated.View:
<Animated.View
style={[likeOverlayStyle]}
className="absolute top-8 left-6 border-4 border-green-400 rounded-lg px-4 py-2 -rotate-12"
>
<Text className="text-green-400 text-3xl font-extrabold">LIKE</Text>
</Animated.View>
<Animated.View
style={[nopeOverlayStyle]}
className="absolute top-8 right-6 border-4 border-red-400 rounded-lg px-4 py-2 rotate-12"
>
<Text className="text-red-400 text-3xl font-extrabold">NOPE</Text>
</Animated.View>
What does this code do?
- Each overlay is an Animated.View positioned absolutely on the card, so it floats over the profile photo.
- The style={[likeOverlayStyle]} connects the opacity to the drag position you defined earlier.
- The -rotate-12 and rotate-12 classes tilt the labels slightly for a playful look.
- Green border and text for LIKE (left side of card). Red border and text for NOPE (right side of card).
- Save SwipeDeck.tsx.
- On your phone, drag the card slowly to the right. You should see a green "LIKE" label fade in near the top-left corner of the card.
- Drag it slowly to the left. You should see a red "NOPE" label fade in near the top-right corner.
Don't see the overlays?
- Make sure the two Animated.View overlay blocks are inside the GestureDetector's Animated.View (the one with cardStyle), not outside it.
- Check that the style={[likeOverlayStyle]} prop matches the variable name exactly. A typo means the style returns undefined and nothing shows.
- If the overlays appear but don't fade, confirm the interpolate input ranges use SWIPE_THRESHOLD (not a hardcoded number).
Help me debug my overlay animations
Confirm match tracking is wired
Your overlays give visual feedback, but the app also needs to remember who you liked. The good news: this wiring already exists in your code from the previous step.
- In SwipeDeck.tsx, look at the handleSwipeComplete function:
const handleSwipeComplete = (direction: "left" | "right") => {
if (direction === "right" && currentIndex < profiles.length) {
addMatch(profiles[currentIndex]);
}
setCurrentIndex((prev) => prev + 1);
};
How does match tracking work?
- When a swipe completes to the right, addMatch(profiles[currentIndex]) saves that profile into the shared MatchesContext.
- Left swipes just advance the index without saving anything.
- The matches state lives in context, so any screen in the app can read it. You will build the Matches screen in the next step to display these saved profiles.
Before you test the full flow, what do you think will happen when you swipe a card past the threshold to the right?
- On your phone, swipe a card to the right past the threshold (about a third of the screen width). The card should fly off-screen with the green LIKE label fully visible.
- Swipe the next card to the left. The card should fly off with the red NOPE label visible.
- Release a card before reaching the threshold. It should spring back to center with both overlays fading back to invisible.
The opacity is proportional to your drag distance. A small drag shows a faint overlay. A full drag shows a solid label.
✔️ Awesome, I've got everything!
Great. Make sure you have saved SwipeDeck.tsx with both the animated styles and the overlay JSX.
ⓧ I'd like to double check the full code
The full SwipeDeck.tsx is now over 30 lines, so scroll through your file and confirm these sections are present in order:
- Imports at the top (unchanged from the previous step).
- SCREEN_WIDTH and SWIPE_THRESHOLD constants.
- State and shared values: currentIndex, addMatch, translateX, translateY.
- handleSwipeComplete function calling addMatch for right swipes.
- panGesture with onUpdate and onEnd handlers.
- cardStyle with rotation interpolation.
- likeOverlayStyle mapping translateX [0, SWIPE_THRESHOLD] to opacity [0, 1].
- nopeOverlayStyle mapping translateX [-SWIPE_THRESHOLD, 0] to opacity [1, 0].
- nextCardStyle with scale interpolation.
- JSX: stacked cards with GestureDetector, ProfileCard, and both LIKE/NOPE Animated.View overlays inside the card view.
Your cards now communicate intent clearly. Drag right and a bold green LIKE appears. Drag left and a red NOPE fades in. Next up, you will build a Matches screen that displays everyone you swiped right on, complete with staggered entry animations.
Build the Animated Matches Screen
Your swipe deck is fully interactive. Cards fly off-screen with spring physics, and the LIKE/NOPE overlays give clear visual feedback as you drag.
But swiping right into the void feels pointless. Liked profiles need a destination. In this step, you'll build a Matches screen where every right-swiped profile appears in a grid with staggered fade-in animations.
In this step, get ready to:
- Confirm the two-tab navigation is working.
- Build the Matches screen with a profile grid.
- Add staggered entering animations to the match cards.
Confirm your tab navigation
Your _layout.tsx already configures two tabs: Discover (with a flame icon) and Matches (with a heart icon). The tab bar should be visible at the bottom of your app right now.
- Look at the bottom of your app on your phone.
- Tap the Matches tab.
You should see either a blank screen or an error. That's because the app/matches.tsx file doesn't exist yet. Expo Router expects a file for every tab route. Let's create it.
Create the matches screen with an empty state
The Matches screen needs to pull liked profiles from the shared context and display them. It also needs a friendly message when no matches exist yet.
- Create a new file called app/matches.tsx and add the following code:
import { View, Text, Image } from "react-native";
import { useMatches } from "../context/MatchesContext";
import Animated, { FadeInDown } from "react-native-reanimated";
export default function MatchesScreen() {
// Read matched profiles from shared context
const { matches } = useMatches();
return (
<View className="flex-1 bg-[#0f0f1a] pt-16 px-4">
<Text className="text-white text-2xl font-bold mb-6">Your Matches</Text>
{matches.length === 0 ? (
<Text className="text-gray-400 text-center mt-20">
No matches yet. Start swiping!
</Text>
) : (
What does this code do?
The useMatches() hook reads the shared matches array that your SwipeDeck populates on right swipes.
The conditional rendering checks if the array is empty. When it is, the screen shows a prompt to start swiping. When matches exist, we'll render them in a grid (the next chunk).
Add the animated profile grid
Each matched profile renders as a smaller card in a two-column grid. The key animation detail: each card uses Reanimated's FadeInDown layout animation with a staggered delay based on its position in the list.
- Directly below the code you just added in app/matches.tsx, add the grid rendering and closing tags:
<View className="flex-row flex-wrap justify-between">
{matches.map((profile, index) => (
<Animated.View
key={profile.id}
// Each card fades in from below with a staggered delay
entering={FadeInDown.delay(index * 100).springify()}
className="w-[48%] mb-4 rounded-2xl overflow-hidden bg-[#1a1a2e]"
>
<Image
source={{ uri: profile.image }}
className="w-full h-48"
resizeMode="cover"
/>
<View className="p-3">
<Text className="text-white font-semibold">
{profile.name}, {profile.age}
</Text>
</View>
</Animated.View>
))}
</View>
)}
</View>
);
}
What does FadeInDown.delay do?
FadeInDown is a layout animation from React Native Reanimated. It slides the element in from below with an opacity fade. The entering prop triggers this animation when the component first mounts.
The .delay(index * 100) staggers each card by 100ms based on its position. The first card enters immediately, the second waits 100ms, the third 200ms, creating a cascading waterfall effect. .springify() replaces the default timing with spring physics for a bouncier feel.
- Save app/matches.tsx.
- On your phone, tap the Matches tab. You should now see the "No matches yet. Start swiping!" message on a dark background.
Still seeing an error on the Matches tab?
Make sure the file is named exactly matches.tsx inside the app/ folder. Expo Router maps tab names to filenames.
Check that your function is exported as export default. Expo Router requires default exports for route components.
If you see a syntax error, confirm that the two chunks connect seamlessly. The ) : ( at the end of chunk 1 should lead directly into the <View className="flex-row... at the start of chunk 2.
Help me debug my Matches screen
Now let's test the full flow. Before you run this test, think about what you expect to happen. You'll swipe a card right (triggering addMatch), then navigate to Matches. What do you think you'll see?
- Tap the Discover tab to return to the swipe deck.
- Swipe a card to the right past the threshold so it flies off-screen.
- Tap the Matches tab.
You should see the profile you just liked appear as a smaller card with a photo and name. It fades in from below with a spring bounce. Swipe a few more cards right, then tap Matches again. Each new card enters with a staggered delay, cascading one after another.
Why is this a different animation pattern?
The swipe deck uses gesture-driven animations where YOUR finger controls the position in real-time. The Matches screen uses layout animations that fire automatically when a component mounts. These are two fundamental categories of animation in Reanimated: interactive (gesture-driven) and declarative (layout-based).
✔️ Awesome, I've got everything!
Great. Double check you've saved app/matches.tsx and confirmed the staggered animation works on your phone.
ⓧ I'd like to double check the full code
Here is the complete app/matches.tsx file. Confirm your code matches this exactly:
import { View, Text, Image } from "react-native";
import { useMatches } from "../context/MatchesContext";
import Animated, { FadeInDown } from "react-native-reanimated";
export default function MatchesScreen() {
const { matches } = useMatches();
return (
<View className="flex-1 bg-[#0f0f1a] pt-16 px-4">
<Text className="text-white text-2xl font-bold mb-6">
Your Matches
</Text>
{matches.length === 0 ? (
<Text className="text-gray-400 text-center mt-20">
No matches yet. Start swiping!
</Text>
) : (
<View className="flex-row flex-wrap justify-between">
{matches.map((profile, index) => (
<Animated.View
key={profile.id}
entering={FadeInDown.delay(index * 100).springify()}
className="w-[48%] mb-4 rounded-2xl overflow-hidden bg-[#1a1a2e]"
>
<Image
source={{ uri: profile.image }}
className="w-full h-48"
resizeMode="cover"
/>
<View className="p-3">
<Text className="text-white font-semibold">
{profile.name}, {profile.age}
</Text>
</View>
</Animated.View>
))}
</View>
)}
</View>
);
}
Your app now has a complete swipe-to-match flow running at 60fps. You can drag cards with spring physics, see intent overlays respond to your finger, and watch liked profiles cascade into the Matches screen with staggered animations. The entire experience runs on your phone with production-quality feel.
Secret mission
Add an Undo Button
Real dating apps let you take back an accidental swipe. Can you build an undo button that springs the last swiped card back into the deck with a satisfying reverse animation?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine and your phone, so there are no ongoing costs. Decide whether to keep your resources as-is or delete them entirely.
Resources you used:
- Local Expo project folder (spark) on your Desktop.
- Expo Go app on your phone.
- Node.js installed on your machine.
Keep everything running
No action needed. Choose this if you are still actively building or want to revisit the app later.
- Your project folder stays on your Desktop. No cloud resources are running.
- Expo Go remains on your phone for future projects.
- Node.js stays installed for other JavaScript or React Native projects.
Pause - I'll come back to this later
Stop the dev server to free up your terminal. Your files stay on disk for when you return.
- In the terminal where Expo is running, press Ctrl+C to stop the dev server.
- Your project folder and all code remain untouched on your Desktop.
- To resume later, open a terminal in your spark folder and run npx expo start again.
Delete - I don't want to use this again
Remove all project files from your machine. No cloud cleanup is needed.
- Stop the Expo dev server by pressing Ctrl+C in the terminal.
- Delete the project folder by running this command:
rm -rf ~/Desktop/spark
- On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\spark
- Delete the Expo Go app from your phone by long-pressing the icon and selecting Remove App (iOS) or Uninstall (Android).
- Node.js can be kept for future projects. If you want to remove it, use your system package manager or the installer from the Node.js website.
Nice Work!
Nice Work!
Nice work! You just built a fully gesture-driven dating app with production-quality animations running at 60fps on your phone.
You've learned how to:
- Built a gesture-driven swipeable card deck using React Native Reanimated shared values, worklets, and interpolation to create smooth spring physics that run entirely on the native UI thread.
- Styled an entire mobile app with NativeWind (Tailwind CSS for React Native) using utility classes for a minimal dark theme, and connected multiple screens with Expo Router file-based navigation.
- Implemented staggered layout animations with FadeInDown entering transitions so liked profiles cascade into view on the Matches screen.
- Secret Mission: Added an undo button with programmatic reverse animations that spring the last swiped card back into the deck.
Ready to quiz yourself?