Blog.

Making a Swipeable Deck of cards in React

Cover Image for Making a Swipeable Deck of cards in React
Gabriel L. Maljkovich
Gabriel L. Maljkovich

What we are building

The goal is that we can swipe the cards up and down, and they will cycle back and forth. We'll use framer/motion to control the animations and typescrypt to keep things tidy. This is what we are building:


Let's get started

First, we need to install the dependencies. Run the following command in an existing react project:

npm i framer-motion

Now, let's create a new file called swipeable-deck.tsx and add the following code:

src/swipeable-deck.tsx
import React, { ReactNode, useState } from "react";
import { motion } from "framer-motion";
 
export type Card = {
  id: number;
  content: ReactNode;
};
 
type SwipeableDeckProps = {
  deck: Card[];
  className?: string;
};
 
export const SwipeableDeck = ({ deck, className }: SwipeableDeckProps) => {
  return ()
}

There we have the basic structure of our component. We have a Card type with an id and content (notice I used ReactNode instead of string so that we can use components if we want to). We also have our SwipeableDeck component that receives a deck prop which is an array of Card objects and an optional className.

Now let's add some basic markup to our component:

src/swipeable-deck.tsx
export const SwipeableDeck = ({ deck, className }: SwipeableDeckProps) => {
  // The vertical padding between each card
  const CARD_OFFSET = 10;
  return (
    <ul className={"deck " + (className || "")}>
      {deck.map((card, index) => (
        <li
          key={card.id}
          className="card"
          style={{ zIndex: deck.length - index, top: index * -CARD_OFFSET }}
        >
          <div className="card-content">{card.content}</div>
        </li>
      ))}
    </ul>
  );
};

We will also need some styles. Create a new file called swipeable-deck.css and add the following code:

src/swipeable-deck.css
.card {
  position: absolute;
  display: flex;
  flex-direction: column;
  justify-content: center;
  width: 250px;
  height: 300px;
  border-radius: 0.5rem;
  border: 1px solid #ddd;
  transform-origin: top center;
  list-style: none;
  background-color: white;
  box-shadow: rgba(0, 0, 0, 0.4) 2px 3px 8px;
}
 
.deck {
  position: relative;
  width: 250px;
  height: 300px;
  margin-top: 64px;
}
 
.card-content {
  font-weight: 300;
  color: #333;
  font-size: 1.5rem;
  text-align: center;
  vertical-align: middle;
}

Then import the styles into our component:

src/swipeable-deck.tsx
import React, { ReactNode, useState } from "react";
import { motion } from "framer-motion";
import "./swipeable-deck.css";

Let's add some cards

Now let's create a new file called index.tsx and add the following code:

src/index.tsx
import React from "react";
import { render } from "react-dom";
import { SwipeableDeck, Card } from "./swipeable-deck";
 
const cards: Card[] = [
  { id: 1, content: "Card 1" },
  { id: 2, content: "Card 2" },
  { id: 3, content: "Card 3" },
  { id: 4, content: "Card 4" },
  { id: 5, content: "Card 5" },
];
 
const App = () => {
  return <SwipeableDeck deck={cards} />;
};
// assuming you have a div with id="root" in your index.html
render(<App />, document.getElementById("root"));

How it looks so far

How it looks so far

If you run your project, you should see a deck of cards stacked on top of each other. Now let's add the swipe functionality.

Enter framer-motion

First, let's add the motion component to our cards. Replace the li tag in the SwipeableDeck component with a motion.li tag.

Then we need to add the swipe functionality. We will use the drag property of the motion component to enable dragging on the y-axis and define an animation using the animate property. We will also add a dragConstraints property to limit the dragging to the y-axis.

src/swipeable-deck.tsx
export const SwipeableDeck = ({ deck, className }: SwipeableDeckProps) => {
  const CARD_OFFSET = 10;
  return (
    <ul className={"deck " + (className || "")}>
      {deck.map((card, index) => {
        // Only the first card is draggable
        const canDrag = index === 0;
        return (
          <motion.li
            key={card.id}
            className="card"
            animate={{
              zIndex: deck.length - index,
              top: index * -CARD_OFFSET
            }}
            drag={canDrag ? "y" : false}
            dragConstraints={{
              top: 0,
              bottom: 0,
            }}
          >
            <div className="card-content">{card.content}</div>
          </motion.li>
      )})}
    </ul>
  );
};

Still not swiping

Still not swiping

If you run your project now, you will see that the first card is draggable, but it doesn't move back to the bottom of the deck. To fix this, we need to add some logic to handle the dragging.

Handling swipes

We will add an onDragEnd event handler to the motion.li component. This handler will check if the card was dragged past a certain threshold and if so, it will move the card to the bottom of the deck.

For framer to animate the change, we need to change the order of the cards in the deck. For that we will introduce a new cards state variable and a move function to move the cards around.

Here's the final code:

/swipeable-deck.tsx
import React, { ReactNode, useState } from "react";
import { motion } from "framer-motion";
 
export type Card = {
  id: number;
  content: ReactNode;
};
 
type SwipeableDeckProps = {
  deck: Card[];
};
 
export const SwipeableDeck = ({ deck, className }: SwipeableDeckProps) => {
  const CARD_OFFSET = 10;
  // add a sense of perspective
  const SCALE_FACTOR = 0.06;
  const [cards, setCards] = useState<Card[]>(deck);
 
  function move(
    array: any[],
    fromIndex: number,
    toIndex: number,
    on = 1
  ): any[] {
    const newArray = array.slice(); // Create a shallow copy of the array
    const movedElements = newArray.splice(fromIndex, on); // Remove the elements to move
    newArray.splice(toIndex, 0, ...movedElements); // Insert the removed elements at the new index
    return newArray;
  }
 
  const moveToEnd = (info: any, from: number) => {
    if (info.offset.y < 0) {
      setCards(move(cards, from, cards.length - 1));
    } else {
      setCards(move(cards, cards.length - 1, from));
    }
  };
 
  return (
    <ul className={"deck " + (className || "")}>
      {cards.map((card, index) => {
        const canDrag = index === 0;
 
        return (
          <motion.li
            key={card.id}
            className="card"
            style={{
              cursor: canDrag ? "grab" : "auto",
            }}
            animate={{
              top: index * -CARD_OFFSET,
              scale: 1 - index * SCALE_FACTOR,
              zIndex: deck.length - index,
            }}
            drag={canDrag ? "y" : false}
            dragConstraints={{
              top: 0,
              bottom: 0,
            }}
            onDragEnd={(event, info) => moveToEnd(info, index)}
          >
            <div className="card-content">{card.content}</div>
          </motion.li>
        );
      })}
    </ul>
  );
};
 

We added a generic move function that moves array elements between specific indexes. We also added a moveToEnd function that moves the card to the end of the deck and hooked it up to the onDragEnd event handler.

To decide whether to move the card to the end of the deck or back to the top, we check the offset.y property of the info object passed to the onDragEnd event handler. If the offset.y is negative, we move the card to the end of the deck, otherwise, we move it back to the top.

Finally working

result

Customizing the deck

Remember we can pass React components as Cards? Let's give it a try by creating a new component called PokerCard in our index.tsx file.

src/index.tsx
import "./index.css"
 
type PokerCardProps = {
  number: string;
  suit: string;
};
 
function PokerCard({ number, suit }: PokerCardProps) {
  return (
    <div className="poker-card">
      <div className="top">
        <div>{number}</div>
        <div className="suit">{suit}</div>
      </div>
      <div className="center">{suit}</div>
      <div className="bottom">
        <div>{number}</div>
        <div className="suit">{suit}</div>
      </div>
    </div>
  );
}

And let's update our cards array to use the PokerCard component.

src/index.tsx
const CARDS: Card[] = [
  {
    id: 1,
    content: <PokerCard suit="♠️" number="A" />,
  },
  {
    id: 2,
    content: <PokerCard suit="♥️" number="A" />,
  },
  {
    id: 3,
    content: <PokerCard suit="♦️" number="A" />,
  },
  {
    id: 4,
    content: <PokerCard suit="♠️" number="3" />,
  },
  {
    id: 5,
    content: <PokerCard suit="♦️" number="3" />,
  },
];

Finally let's create some styles for our PokerCard component.

src/index.css
.poker-card {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 0.25rem;
  height: 100%;
  font-weight: 500;
}
 
.poker-card .top {
  width: 2rem;
  text-align: center;
}
 
.poker-card .top .suit,
.poker-card .bottom .suit {
  font-size: 14px;
}
 
.poker-card .center {
  text-align: center;
  font-size: 3.5rem;
}
 
.poker-card .bottom {
  width: 2rem;
  text-align: center;
  float: right;
  align-self: self-end;
  margin-bottom: 0.5rem;
  transform: rotate(180deg);
}

And that's it! You can now run your project and see the PokerCard component in action.

poker

Bonus

With a few more tweaks you can make a credit card stack as well. Here's an example: