Boise ID
00 00
Do you think it's going to rain today?
Building a Tinder-style swipe deck in Expo and React Native
A Tinder-inspired swiping system can offer an engaging user experience. This post will cover working with gestures and animations in Expo and React Native as well as one way to implement the logic & component component controllers for a swipe deck.

Introduction

The swipe deck is a novel and intuitive way for a user to quickly interact with and sort through a long series of items. For those of you unfamiliar with Tinder, I envy you, and the concept is simple: a user is presented with one card at a time. They can swipe left to reject the card or swipe right to accept it. The card then animates off the screen, and the next card is shown.

This is an outright satanic way to perceive and interact with other human beings, but I recently worked on a project where I thought the swipe deck would be a good (and strictly ethical) fit. I built a swipe deck in Expo and React Native, and I want to share my approach with you.

Setting up the project

We'll start by creating a new Expo project, and following the instructions to reset the project to a blank state.

npx create-expo-app@latest cd my-new-project npm run reset-project

Expo recommends a package called react-native-reanimated for high-performance animations, and that package integrates tightly with react-native-gesture-handler. We'll install both of these packages now.

npx expo install react-native-reanimated react-native-gesture-handler

Building the Swipe Deck

We'll start by creating a new component called SwipeDeck. This component will be responsible for rendering the cards and handling the swipe gestures.

SwipeDeck.tsx
import { useState } from "react"; import { View } from "react-native"; import { Card } from "./Card"; export function SwipeDeck() { const [cards, setCards] = useState([ "Card 1", "Card 2", "Card 3", "Card 4", "Card 5", ]); return ( <View style={{ flex: 1 }}> {cards.map((card) => ( <Card key={card} title={card} /> ))} </View> ); }

And a component called Card. This component will draw the card and handle the animations.

Card.tsx
import { View, Text, StyleSheet } from "react-native"; export function Card({ title }: { title: string }) { return ( <View style={styles.card}> <Text style={styles.title}>{title}</Text> </View> ) } const styles = StyleSheet.create({ card: { width: '100%', height: '100%', padding: 20, position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'white', }, title: { fontSize: 24, }, });

Since this component is only responsible for presentation, we're passing the data (in our case, the title) as a prop and simply rendering it in a View.

The other important part of the Card component is the position: 'absolute' style and the position properties. This ensures the cards are stacked on top of each other and can be animated off the screen.

The bottom card is visible in the deck.
What we have so far (notice that the bottom card is visible on top of the deck)

On not using z-index

Those of you who are coming from a web background will be used to rearranging the stacking order of elements with the z-index property. When building cross-platform mobile apps with React Native, there's a bit of a gotcha: Android's native rendering engine has a competing concept called elevation.

Combining z-index and elevation can lead to unexpected bugs. In our case, since reversing the order of the cards in the array is trivial, we'll use that approach instead.

Here's our updated SwipeDeck component:

SwipeDeck.tsx
export function SwipeDeck() { const [cards, setCards] = useState([ "Card 1", "Card 2", "Card 3", "Card 4", "Card 5", ]); const cardEls = cards.map((card) => ( <Card key={card} title={card} /> )).reverse(); return ( <View style={{ flex: 1 }}> {cardEls} </View> ); }

Handling gestures

As I mentioned in the introduction, we're using react-native-gesture-handler to handle the swipe gestures and react-native-reanimated to animate the cards off the screen.

The first thing we'll need to do is add the GestureHandlerRootView component to our root component. If you're working from a blank project, this will be your _layout.tsx file.

_layout.tsx
import { Stack } from "expo-router"; import { GestureHandlerRootView } from "react-native-gesture-handler"; export default function RootLayout() { return ( <GestureHandlerRootView style={{ flex: 1 }}> <Stack> <Stack.Screen name="index" /> </Stack> </GestureHandlerRootView> ); }

The next thing we'll need to do is add a sharedValue to track the position of the card. If you have experience with Framer Motion, this is similar to a motionValue. It doesn't cause React re-renders when it's updated, and it accepts a variety of types to animate.

It's called a "shared" value not because it's shared between components, but because it's shared between the UI thread and the JavaScript thread.

We're also going to add replace our card's view with an animated view (Animated.View) and create an animated style (useAnimatedStyle) to handle the animation.

Card.tsx
import { Text, StyleSheet } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; export function Card({ title }: { title: string }) { const offset = useSharedValue(0); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }], })) return ( <Animated.View style={[styles.card, animatedStyle]} > <Text style={styles.title}>{title}</Text> </Animated.View> ) }

Next, we'll add our gesture handler.

Card.tsx
const pan = Gesture.Pan() .onChange((event) => { offset.value = event.translationX; }) .onFinalize((event) => { if (event.translationX < -50) { offset.value = withSpring(-500); } else if (event.translationX > 50) { offset.value = withSpring(500); } else { offset.value = withSpring(0); } })

There are a few things going on here:

  • In the onChange event, we're setting the offset's value directlya very straightforward way to update the position of the card
  • In the onFinalize event, we're measuring the distance of the pan to determine whether the user has swiped left or right and we're also returning the card to its starting position if they didn't swipe far enough in either direction.
  • We're using withSpring to do the legwork of the animation. This will ensure the card snaps to its destination with a nice, smooth, kinetic animation.

Let's give it a try!

This is already something we can work with. We can swipe the cards left and right and they smoothly animate off the screen.

However, that's all they do. They'll pile up off the screen and stay there until something bad happens. We need to add some logic to remove the cards from the array when they're swiped off the screen.

At the same time, we'll add logic for limiting the deck to two cards (as that's as many as you can see at once) and for adding new cards to the deck when the user swipes the top card.

Responding to gestures

As is the React way, we'll pass a function to the Card component that will handle the swipe gestures.

SwipeDeck.tsx
const handleSwipe = useCallback(() => { setCards((prev) => prev.slice(1)); }, []) const cardEls = cards .slice(0, 2) .map((card) => ( <Card key={card} title={card} handleSwipe={handleSwipe} /> )) .reverse();

There are two valid approaches here: handling the swipe gesture in the Card component or handling it in the SwipeDeck component.

Handling it within the SwipeDeck component addresses separation of concerns somewhat better (the cards are only responsible for rendering themselves), but in modern React it's pretty likely that your business logic methods for interacting with a specific Card will be handled within a hook in the Card component.

What's ultimately preferable is up to you and your project's needs.

Either way, we need to react to swipes within the SwipeDeck component to manage the state of the deck. All we're doing here is removing the top card from the deck after it's been swiped.

Next, we'll add a handleSwipe prop to the Card component and call it from our gesture handler.

Here's the new gesture handler:

Card.tsx
const pan = Gesture.Pan() .onChange((event) => { offset.value = event.translationX; }) .onFinalize((event) => { if (event.translationX < -50) { offset.value = withSpring(-500, springConfig, () => { runOnJS(handleSwipe)(); }); } else if (event.translationX > 50) { offset.value = withSpring(500, springConfig, () => { runOnJS(handleSwipe)(); }); } else { offset.value = withSpring(0); } })

Notice the use of a new method, runOnJS. react-native-gesture-handler offloads gesture handling to the native thread, to keep the UI smooth and responsive. React, however, lives in the JavaScript thread. If you try to directly call your common React functions from the gesture handler, the app will crash.

runOnJS, imported from react-native-gesture-handler, allows us to call our JavaScript functions from the native thread.

We're calling the handleSwipe function from inside an anonymous function passed to the withSpring function. This third argument is a callback that will be called when the animation is complete.

In conjunction with adding configuration to our spring animation, this ensures that the card is removed from the deck only after the animation is completeand that it doesn't hang around too long, either.

MacCarrithers