Make your own card carousel in React

siaust

Simon Aust

Posted on July 30, 2021

Make your own card carousel in React

When you have any sort of group of similar things, when it comes to presenting them you have a number of choices. You can use grids, tables, flexbox, they all do the job, but maybe you want to add a little bit of style and responsiveness to your page? If so, lets create our own carousel, a group of items we can swipe through with pointer events to add some interactivity. In the age of Instagram and Tinder, who doesn't like to swipe?

Contents

To make a functioning carousel you may only need to complete the first section, and then you'll have all you need to take it further yourself. I have added basic CSS to the layout and won't be adding all of the styles here to keep things concise, but you may check it out in the project repository and import it to match the styles seen.
NB: I'll use ellipses (...) to signify removed code in some parts to shorten code blocks.

Setting up the carousel

First thing we need is some data, which we will populate our cards with. Lets keep it simple, we can use a Javascipt array of objects and import them into our main app.js. Here's an example of some data, by all means edit or add your own touch to this.



export const data = [
  {
    name: "simon",
    img: "https://imgur.com/c43aAlv.jpg",
  },
  {
    name: "neo",
    img: "https://imgur.com/RF2a3PB.jpg",
  },
  {
    name: "morpheus",
    img: "https://imgur.com/B0SNpZI.jpg",
  },
  {
    name: "trinity",
    img: "https://imgur.com/KnXHM0K.jpg",
  },
];



Enter fullscreen mode Exit fullscreen mode

Here we have a small array with some objects that have a name and img property. We will use these to populate the cards in the carousel later on.

In our App.js file we can add an import for the data like so - import {data} from "./data" - ready for later. This is a named import, so make sure to get the variable name matching your export variable. Onward to building our carousel!

Building the components of the carousel

First of all we need to make a component that will sit inside our carousel, the object which will slide across the screen. In this case I will call it a card and create it as a React component as so -



const Card = ({ name, img }) => {
  return (
    <div className="card">
      <img src={img} alt={name} />
      <h2>{name}</h2>
    </div>
  );
};

export default Card;


Enter fullscreen mode Exit fullscreen mode

A simple component just holds two items, an image and a heading for our object name property. You can see the props are passed down into this component, lets set that up now from our data.js.

In App.js we will iterate over the data using the map() function and populate our root element with cards -



import "./App.css";

import Card from "./components/Card";
import { data } from "./data";

function App() {
  return (
    <div className="App">
      <div className="container">
        {data.map((person) => {
          return <Card {...person} />;
        })}
      </div>
    </div>
  );
}

export default App;


Enter fullscreen mode Exit fullscreen mode

We're using the map() function to iterate over the data and create a new Card for each person, passing in the properties using the spread operator. We already know the names of these properties match the component arguments, but it's one thing to look out for if your card doesn't display as you expect.

Now you should have something that looks like this (as long as you applied the App.css from the repo) -
group of person cards in React project

Positioning the cards

Now we need to work on our carousel. In a carousel cards typically slide from the right or left, so we need to position our cards in some order, lets say "prevCard" on the left, "nextCard" to the right. These will be CSS classes we give the cards depending on their position.

Firstly we will add position: absolute to the card CSS class, this stacks all our cards on top of each other. Now we create some new CSS styles prevCard, activeCard and nextCard -



.prevCard {
  left: 0;
}

.activeCard {
  left: 50%;
  transform: translateX(-50%); /* the card is centered 
                               /* to itself
                               /* instead of the left edge
                               /* resting on the center line
}

.nextCard {
  right: 0;
}


Enter fullscreen mode Exit fullscreen mode

The next question is under what condition do we apply those styles to the cards? Well in the map() function we can add a parameter to read the current iteration index, data.map((person, index) => {}). This gives us the ability to apply the styles depending on a condition. But what condition? For example, we can say any card greater than index equal to zero should have the style nextCard applied. Lets look at the Javascript for this -



{data.map((person, index) => {
    let position = index > 0 ? "nextCard" : index === 0 ? 
        "activeCard" : "prevCard";
    return <Card {...person} cardStyle={position} />;
})}


Enter fullscreen mode Exit fullscreen mode

We're using nested ternary operators here to check the index and apply a style to the card, which we pass down as a prop. We also need to update the card component to take a new parameter cardStyle and apply that to the className attribute. We can use a template string to concatinate the new style with our required card style like so -



const Card = ({ name, img, cardStyle }) => {
  return (
    <div className={`card ${cardStyle}`}>
...


Enter fullscreen mode Exit fullscreen mode

If you save the app you may now see something like this -
Alt Text

Using Chrome dev tools and highlighting the container element, the problem here is that the nextCard "card" is positioning itself to its nearest positioned relative, of which there are none, so in this case is it the root element. We need to add a container, which will hold the cards and allow us to position them where we want.



// css
.card-container {
  position: relative;

  width: 36rem;
  height: 22rem;
}


Enter fullscreen mode Exit fullscreen mode

For simplicity sake, we're setting the width of the card container to three cards width, accounting for margin. This will allow a nice transition later on.



// App.js
<div className="container">
    <div className="card-container"> /* wrap cards */
        {data.map((person, index) => {
            let position =
                index > 0 ? "nextCard" : index === 0 ? 
                "activeCard" : "prevCard";
            return <Card {...person} cardStyle={position} />;
        })}
    </div>
</div>


Enter fullscreen mode Exit fullscreen mode

So we've positioned our cards, we can now add some controls to move them. Lets just use FontAwesome icons for this. You can find instructions for using FontAwesome and React here. We can simply use the faChevronLeft and faChevronRight. Once we've imported them, we can position them absolutely, and give them a onclick function, which we'll work on next.



import { FontAwesomeIcon } from "@fortawesome/react
fontawesome";
import { faChevronLeft, faChevronRight} from "@fortawesome/free-solid-svg-icons";


Enter fullscreen mode Exit fullscreen mode

Carousel function

There is a glaring problem here. There is no previous card! This has been determined by our condition in the map function, so we need to fix this. At the same time, we can link in some functionality to our onClick handler, and also utilise useState hook from React. Lets break it down.

We need a starting point for our cards, an index, so we set up some state with a value of zero. We import the hook and declare our state variables -



import {useState} from "react";

const [index, setIndex] = useState(0)


Enter fullscreen mode Exit fullscreen mode

We are going to change this state value with our onClick functions and instead of comparing a hardcoded value 0, we are going to compare the index of the map function with the state. This allows use to change the condition which applies the styles to the cards. First the functions -



const slideLeft = () => {
    setIndex(index - 1);
};

const slideRight = () => {
    setIndex(index + 1);
};


Enter fullscreen mode Exit fullscreen mode

Update the FontAwesomeIcon component with a onClick function -



<FontAwesomeIcon
    onClick={slideLeft}
    className="leftBtn"
    icon={faChevronLeft}
/>
<FontAwesomeIcon
     onClick={slideRight}
     className="rightBtn"
     icon={faChevronRight}
/>


Enter fullscreen mode Exit fullscreen mode

Finally the condition is updated to compare with the state value (updated the map index with a new name n) -



{data.map((person, n) => {
    let position = n > index ? "nextCard" 
        : n === index ? "activeCard" : "prevCard";
    return <Card {...person} cardStyle={position} />;
})}


Enter fullscreen mode Exit fullscreen mode

On testing I had some issues at this point with the transitions, and discovered this to be my mistake when using the position properties left and right with the CSS. It creates a smooth tranistion if you stick to the same property throughout, although this meant I had to use some tweaks to get the cards in the right places, using the CSS calc() function. The updated CSS for the cards -



.prevCard {
    left: calc(0% + 2rem);
    opacity: 0;
}

.activeCard {
  left: 50%;
  transform: translateX(-50%);
}

.nextCard {
    left: 100%;
    transform: translateX(calc(-100% - 2rem));
    opacity: 0;
}


Enter fullscreen mode Exit fullscreen mode

This will nicely position the cards left, center and right throughout the transition, accounting for the margin. Note the opacity: 0 property, this is the result -

Here is with no change to opacity, so you may see easily what is happening -

Woo! Looks pretty nice! I'm sure you're already thinking of awesome ways to improve this, but firstly we just need to improve our function and stop changing the state if our index goes out of bounds to the data length. Otherwise, we could keep clicking forward, or backward for eternity, and the state would keep changing.



 const slideLeft = () => {
    if (index - 1 >= 0) {
      setIndex(index - 1);
    }
  };

  const slideRight = () => {
    if (index + 1 <= data.length - 1) {
      setIndex(index + 1);
    }
  };


Enter fullscreen mode Exit fullscreen mode

A couple of simple if conditions keep us within bounds and we can happily scroll left and right without a worry.


Adding Mouse Events

Pointer events are things like a mouse clicking, dragging, moving over an element. We've already used one, onClick, in our FontAwesomeIcon component to trigger a card to move. Want would be nice is if we can click and drag, and pull the card across the screen. We can do this with some other MouseEvent's that are available to us, like onMouseDown, onMouseMove and onMouseUp.

First we'll make a test function to see everything is working.



const handleMouseDown = (e) => {
    console.log(e.target);
  };


Enter fullscreen mode Exit fullscreen mode

Now we pass this function as a prop to our Card component and give the onMouseDown attribute this function in the container div.



// App.js
<Card
    handleMouseDown={handleMouseDown}
    {...person}
    cardStyle={position}
/>
// Card.js
const Card = ({ handleMouseDown, name, img, cardStyle }) => {
    return (
        <div 
            className={`card ${cardStyle}`} 
            onMouseDown={handleMouseDown}>
...


Enter fullscreen mode Exit fullscreen mode

Now if we click on a few cards we will see in the Chrome console something like -
Alt Text
On each click the event object is passed to our function which we use to log the target, which is the card. We can use the event to get the element we should move, the starting position of X, and use document.onMouseMove to track the cursors position. Once we have that, we can change the CSS left position property to reflect what the mouse does.

Firstly you may notice when dragging the card from the image it will be pulled along with your cursor. We need to stop this to prevent it interfering with our dragging of the card, we can do this in CSS by applying pointer-events: none; to the image. Other than that you may also be getting some selection happening when the mouse drags over the heading and image, to prevent that we can use user-select: none in the card class. An alternative if you want or need to allow selection is to have a specific area of the card as the draggable area, for this you would set your onMouseDown handler function to that particular element of the card, like a <header> or any other element you want.

So once that's sorted, now lets look at the function we need to track our mouse event -



const handleMouseDown = (e) => {
    /* this is our card we will move */
    let card = e.target;
    /* to keep track of the value to offset the card left */
    let offset = 0;
    /* keeps the initial mouse click x value */
    let initialX = e.clientX;
    /* set the documents onmousemove event to use this function*/
    document.onmousemove = onMouseMove;
    /* sets the documents onmouseup event to use this function */
    document.onmouseup = onMouseUp;

    /* when the mouse moves we handle the event here */
    function onMouseMove(e) {
      /* set offset to the current position of the cursor,
      minus the initial starting position  */
      offset = e.clientX - initialX;

      /* set the left style property of the card to the offset 
      value */
      card.style.left = offset + "px";
    }

    function onMouseUp(e) {
      /* remove functions from event listeners
      (stop tracking mouse movements) */
      document.onmousemove = null;
      document.onmouseup = null;
    }
};


Enter fullscreen mode Exit fullscreen mode

Now there's a few issues, sadly. First of all you'll immediately notice what feels like mouse lag. This is the transition CSS property on the card slowing down it's movement as it animates between positions. You can comment that out to fix it, but of course this will disable the nice animation when clicking the left/right chevrons. The second issue is that when we move the card left is instantly set to a pixel value and the card appears to jump left. This is definitely not what we want! We can fix both these problems by adding a(nother!) container around our card, which will take on the transition property and our card will be aligned within, so there will be no jump left.

First we wrap our card with a <article> tag, trying to follow HTML semantics, that will be what is positioned in the card container, and have the transition. The actual card will be absolutely position to this element, so when changing its left property, there won't be any oddness, as it hasn't previously been set.



// Card.js
<article className={cardStyle}> /* class now applies here */
    <div className="card" onMouseDown={handleMouseDown}>
        <img src={img} alt={name} />
        <h2>{name}</h2>
    </div>
</article>


Enter fullscreen mode Exit fullscreen mode


article {
    position: absolute;
    width: 12rem;
    height: 100%;

    transition: all 1s; /* cut from .card class */
}


Enter fullscreen mode Exit fullscreen mode

Now that the card is kind-of draggable, you will notice that the other cards, previous and next, are interfering when you drag the visible card near them. We fix this by adding a <div> with a sole purpose of "hiding" these elements, by using z-index. We create a div called, creatively, background-block and give it a z-index: 0 and append our other elements accordingly. prevCard and nextCard get a z-index: -1.



// App.js
<div className="card-container">
          <div className="background-block"></div>
          ...


Enter fullscreen mode Exit fullscreen mode


.background-block {
  position: absolute;
  width: 100%;
  height: 100%;
  z-index: 0;
}


Enter fullscreen mode Exit fullscreen mode

This is what you should see -

The last thing we need to do, the whole point of this, is to trigger the slide to the next or previous card. We go back to our handleMouseDown function for this, and add some conditions checking the value of x. Inside onMouseMove we add -



function onMouseMove(e) {
    ...
    if (offset <= -100) {
        slideRight();
        return;
    }
    if (offset >= 100) {
        slideLeft();
        return;
    }
    ...
}


Enter fullscreen mode Exit fullscreen mode

One last issue (I promise!), you'll notice that the cards retain the position after sliding back and forth. We can fix this by resetting their left property in the same block of code.



if (offset <= -100) {
        slideRight();
        /* if we're at the last card, snap back to center */
        if (index === data.length - 1) {
          card.style.left = 0;
        } else {
          /* hide the shift back to center 
        until after the transition */
          setTimeout(() => {
            card.style.left = 0;
          }, 1000);
        }
        return;
      }
      if (offset >= 100) {
        slideLeft();
        /* if we're at the first card, snap back to center */
        if (index === 0) {
          card.style.left = 0;
        } else {
          /* hide the shift back to center 
        until after the transition */
          setTimeout(() => {
            card.style.left = 0;
          }, 1000);
        }
        return;
      }


Enter fullscreen mode Exit fullscreen mode

Also, if the user releases the mouse before +- 100 pixels, the card will "stick", we can sort that in the onMouseUp function -



function onMouseUp(e) {
    /* if user releases mouse early,
    card needs to snap back */
    if (offset < 0 && offset > -100) {
        card.style.left = 0;
    }
    if (offset > 0 && offset < 100) {
        card.style.left = 0;
    }
    ...


Enter fullscreen mode Exit fullscreen mode

Actually, slight adjustments can be made to the style of prevCard; left:0; and nextCard; transform: translateX(-100%); - to keep a nice spacing after the change to wrapping with <article> element.

Et voila!


Carousel Pagination

Another optional thing we can do is add some visual feedback of where we are in the carousel. You can think of this as a form of pagination, although it's just for visual reference.

First we'll make a new component called Paginator. It will take two props, one is the length of the data, i.e. how many dots to represent the cards, and an index value which represents which card is active so we can style the respective dot to reflect this.

Here's our component -



const Paginator = ({ dataLength, activeIndex }) => {
    let dots = [];
    let classes = "";
    for (let index = 0; index < dataLength; index++) {
        classes = activeIndex === index ? "dot active" : "dot";
        dots.push(<div key={index} className={classes}></div>);
    }

    return (
        <div className="paginator">
            <div className="hr"></div> {/* horizontal rule */}
            {dots.map((dot) => dot)}
        </div>
    );
};

export default Paginator;


Enter fullscreen mode Exit fullscreen mode

You can see here we are using the dataLength to populate an array with JSX objects. One of those objects is give a class active, which will set it apart from the others. The CSS is straight forward and can be found in the repo (link at top).

In App.js we simply import our component and pass in the data.length and state value index. When we slide the carousel, the state value changes and the Paginator will receive this new value and render the updates accordingly.



//App.js
...
<div className="card-container">
    <Paginator dataLength={data.length} activeIndex={index} />
...


Enter fullscreen mode Exit fullscreen mode


To make the dots clickable we can add a function to the onClick attribute like normal. We'll pass this function down from App.js into the Paginator.js component.



//App.js
const handlePageChange = (page) => {
    let n = page - index;
    setIndex(index + n);
};
<Paginator
    ...
    handlePageChange={handlePageChange}
/>
//Paginator.js
onClick={() => handlePageChange(index)}


Enter fullscreen mode Exit fullscreen mode

Basically the onClick function passing in a argument which is the index of the map function, for simplicity. This identifies what "page" it is, and we compare this with the state value. Then we can simply add the number (positive or negative) to set our index state and trigger a render.


Make it mobile friendly

Earlier we added mouse events which handled clicking and dragging a card to trigger the functions which slide the cards. To make our carousel mobile friendly, we also need to add another kind of pointer event, called TouchEvent's.

In our Card components <article> element we should add a new attribute onTouchStart. This event is fired when a tablet or phone has a finger or stylus touch the screen. We'll point it to the same function that handles our mouse events and make some changes there. We should also rename the argument to better reflect that it now handles pointer events, rather than just mouse events.



// Card.js
<article className={cardStyle}>
      <div className="card" onMouseDown={handlePointerEvent} 
      onTouchStart={handlePointerEvent}>
...


Enter fullscreen mode Exit fullscreen mode

In App.js we rename handleMouseDown to handlePointerEvent and then add a variable to check what type of event we're getting.



let isTouchEvent = e.type === "touchstart" ? true : false;


Enter fullscreen mode Exit fullscreen mode

We can use this flag a few more times when we are setting the X coordinate, again using ternary operators. Updating the code changes to -



function onPointerEvent(e) {
    ...
    let initialX = isTouchEvent ? e.touches[0].clientX : 
        e.clientX;
    ...
    function onPointerMove(e) {
        ...
        offset = (isTouchEvent ? e.touches[0].clientX : 
            e.clientX) - initialX;
        ...
    }
...
}


Enter fullscreen mode Exit fullscreen mode

You may notice that we're checking the first index of an array of the touch object. This is because many devices can use multi-touch, so you could track one or more fingers if you wished, for example using pinch to zoom. We don't need to track more than one though, so we just check the first, zeroth, finger/stylus.

We also need to add the functions to the documents touch event listeners, as we did before with the mouse events. We remove them when the touch ends, just like when the mouse click finished. This prevents our functions being called after we're done with them.



// handlePointerEvent
document.ontouchmove = onPointerMove;
document.ontouchend = onPointerEnd;

// onPointerEnd
document.ontouchmove = null;
document.ontouchend = null;



Enter fullscreen mode Exit fullscreen mode

Now if you check it out in Chrome dev tools with mobile view it works, but there is some issues when a card slides off screen to the right, expanding the view and causing scrollbars to appear briefly. We can fix this using media queries but hiding the overflow and restyling the elements slightly.



@media screen and (max-width: 425px) {
  .container {
    width: 100%;
    overflow: hidden;
  }

  .card-container {
    width: 80%;
  }

  .prevCard {
    left: -35%;
  }

  .nextCard {
    left: 135%;
  }
}


Enter fullscreen mode Exit fullscreen mode

This is just for one screen width of 425px and less, if you want to support more widths you'll need to do a bit more testing and add more media queries to reposition.

That's it! We've done it, a nice carousel, with touch and is responsive. Lets see the final product -






Phew, I hope you found some interesting things here and it helps you out. At the end of the day it's a basic carousel but by working through the process to create it I hope it gives you ideas of what else can be achieved. Thanks for reading! If you have any comments of suggestions please do add them below.

Cover photo by picjumbo.com from Pexels
💖 💪 🙅 🚩
siaust
Simon Aust

Posted on July 30, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related