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
- Making a Carousel
- (Optional) Adding Mouse Events
- (Optional) Carousel Pagination
- (Optional) Make it Mobile Friendly
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",
},
];
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;
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;
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) -
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;
}
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} />;
})}
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}`}>
...
If you save the app you may now see something like this -
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;
}
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>
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";
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)
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);
};
Update the FontAwesomeIcon
component with a onClick
function -
<FontAwesomeIcon
onClick={slideLeft}
className="leftBtn"
icon={faChevronLeft}
/>
<FontAwesomeIcon
onClick={slideRight}
className="rightBtn"
icon={faChevronRight}
/>
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} />;
})}
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;
}
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);
}
};
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);
};
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}>
...
Now if we click on a few cards we will see in the Chrome console something like -
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;
}
};
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>
article {
position: absolute;
width: 12rem;
height: 100%;
transition: all 1s; /* cut from .card class */
}
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>
...
.background-block {
position: absolute;
width: 100%;
height: 100%;
z-index: 0;
}
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;
}
...
}
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;
}
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;
}
...
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.
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;
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} />
...
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)}
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}>
...
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;
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;
...
}
...
}
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;
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%;
}
}
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
Top comments (2)
Thanks buddy. A quick question, I wanna design a slightly different version of your post but TBH IDK how to do it, So I just describe it here and if you had time you may wanna add it to this post: My carousel allow user to interact with it and user can see multiple item at the same time but one prominently displayed. Then user can navigate between them and or selecting a specific card to bring it to the foreground, also when user reaches the end of the show list in each side - left, or right - it automatically shows more option on that side.
Thank you for this. I can't wait to try it.