I have a custom carousel component (using React, Typescript and Tailwind) -
import React, { useState } from 'react'
interface Slide {
content: React.ReactNode
title: string
description: string
}
interface CarouselProps {
slides: Slide[]
}
const Carousel: React.FC<CarouselProps> = ({ slides }) => {
const [currentIndex, setCurrentIndex] = useState(0)
const [nextIndex, setNextIndex] = useState<null | number>(null)
const [animationClass, setAnimationClass] = useState('')
const goToPrevious = () => {
setNextIndex(currentIndex === 0 ? slides.length - 1 : currentIndex - 1)
setAnimationClass('slide-out-right') // Current slide exits to the right
}
const goToNext = () => {
setNextIndex(currentIndex === slides.length - 1 ? 0 : currentIndex + 1)
setAnimationClass('slide-out-left') // Current slide exits to the left
}
const goToSlide = (index: number) => {
if (index > currentIndex) {
setAnimationClass('slide-out-left')
setTimeout(() => setNextIndex(index), 5000)
} else if (index < currentIndex) {
setAnimationClass('slide-out-right')
setTimeout(() => setNextIndex(index), 5000)
}
}
const handleAnimationEnd = () => {
if (nextIndex !== null) {
setCurrentIndex(nextIndex)
setNextIndex(null)
setAnimationClass(
nextIndex > currentIndex ? 'slide-in-left' : 'slide-in-right'
)
}
}
return (
<div className="carousel-container flex flex-col items-center">
<div className="relative flex h-[250px] w-[400px] items-center justify-center">
<button
onClick={goToPrevious}
className="absolute left-0 -translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
‹
</button>
<div className="carousel-content flex size-full items-center justify-center overflow-hidden rounded-lg bg-gray-100">
<div
className={`${animationClass} flex size-full items-center justify-center`}
onAnimationEnd={handleAnimationEnd}
>
{slides[nextIndex !== null ? nextIndex : currentIndex].content}
</div>
</div>
<button
onClick={goToNext}
className="absolute right-0 translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
›
</button>
</div>
<div className="carousel-text mt-4 w-[400px] text-center">
<h3 className="text-lg font-semibold">{slides[currentIndex].title}</h3>
<p className="text-gray-600">{slides[currentIndex].description}</p>
</div>
<div className="carousel-indicators mt-2 flex space-x-1">
{slides.map((_, index) => (
<span
key={index}
onClick={() => goToSlide(index)}
className={`block size-2 cursor-pointer rounded-full ${
index === currentIndex ? 'bg-black' : 'bg-gray-300'
}`}
/>
))}
</div>
</div>
)
}
export default Carousel
I have these animations defined in my globals.css -
@keyframes slide-in-left {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slide-in-right {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes slide-out-left {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
@keyframes slide-out-right {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
.slide-in-left {
animation: slide-in-left 0.5s ease-in-out forwards;
}
.slide-in-right {
animation: slide-in-right 0.5s ease-in-out forwards;
}
.slide-out-left {
animation: slide-out-left 0.5s ease-in-out forwards;
}
.slide-out-right {
animation: slide-out-right 0.5s ease-in-out forwards;
}
At the moment, the logic is behaving strangely and I can't seem to figure out what I'm doing wrong. At the moment, the animation isn't smooth and the content is sliding the wrong way when navigating.
Intended logic - When the user navigates right, the content should slide out to the left and the new content should slide in from the right. This should work vice versa too. If I navigate left, the content slides out to the right and the new content slides in from the left.
Consider using CSS transitions instead of CSS animations to animate the slides. This would mean you would not need the setTimeout()
calls or needing to manage next or previous slides.
First, position each of the slides one on top of each other using absolute positioning:
const { useState } = React;
const Carousel = ({ slides }) => {
const [currentIndex, setCurrentIndex] = useState(0);
const goToPrevious = () => {};
const goToNext = () => {};
const goToSlide = () => {};
return (
<div className="carousel-container flex flex-col items-center">
<div className="relative flex h-[250px] w-[400px] items-center justify-center">
<button
onClick={goToPrevious}
className="absolute left-0 -translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
‹
</button>
<div className="relative size-full overflow-hidden rounded-lg bg-gray-100">
{slides.map(({ content }, i) => (
<div
key={i}
className="flex size-full items-center justify-center absolute inset-0"
>
{content}
</div>
))}
</div>
<button
onClick={goToNext}
className="absolute right-0 translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
›
</button>
</div>
<div className="carousel-text mt-4 w-[400px] text-center">
<h3 className="text-lg font-semibold">{slides[currentIndex].title}</h3>
<p className="text-gray-600">{slides[currentIndex].description}</p>
</div>
<div className="carousel-indicators mt-2 flex space-x-1">
{slides.map((_, index) => (
<span
key={index}
onClick={() => goToSlide(index)}
className={`block size-2 cursor-pointer rounded-full ${
index === currentIndex ? 'bg-black' : 'bg-gray-300'
}`}
/>
))}
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('app')).render(
<Carousel
slides={[
{ content: 'Foo' },
{ content: 'Bar' },
{ content: 'Baz' },
{ content: 'Qux' },
]}
/>
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js" integrity="sha512-QVs8Lo43F9lSuBykadDb0oSXDL/BbZ588urWVCRwSIoewQv/Ewg1f84mK3U790bZ0FfhFa1YSQUmIhG+pIRKeg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js" integrity="sha512-6a1107rTlA4gYpgHAqbwLAtxmWipBdJFcq8y5S/aTge3Bp+VAklABm2LO+Kg51vOWR9JMZq1Ovjl5tpluNpTeQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.tailwindcss.com/3.4.5"></script>
<div id="app"></div>
Then have the each slide either be to the left or the right of the slider depending on where it is relative to the active slide:
currentIndex = 0:
┏━━━━━┓–––––┐
┃ 0 ┃1,2,3│
┗━━━━━┛–––––┘
currentIndex = 1:
┌–––––┏━━━━━┓–––––┐
│ 0 ┃ 1 ┃ 2,3 │
└–––––┗━━━━━┛–––––┘
currentIndex = 2:
┌–––––┏━━━━━┓–––––┐
│ 0,1 ┃ 2 ┃ 3 │
└–––––┗━━━━━┛–––––┘
currentIndex = 3:
┌–––––┏━━━━━┓
│0,1,2┃ 3 ┃
└–––––┗━━━━━┛
This gives the slide in effects:
const { useState } = React;
const Carousel = ({ slides }) => {
const [currentIndex, setCurrentIndex] = useState(0);
const goToPrevious = () => {
setCurrentIndex((i) => (i === 0 ? slides.length - 1 : i - 1));
};
const goToNext = () => {
setCurrentIndex((i) => (i === slides.length - 1 ? 0 : i + 1));
};
return (
<div className="carousel-container flex flex-col items-center">
<div className="relative flex h-[250px] w-[400px] items-center justify-center">
<button
onClick={goToPrevious}
className="absolute left-0 -translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
‹
</button>
<div className="relative size-full overflow-hidden rounded-lg bg-gray-100">
{slides.map(({ content }, i) => {
const animateClass =
i === currentIndex
? ''
: i < currentIndex
? '-translate-x-full'
: 'translate-x-full';
return (
<div
key={i}
className={`${animateClass} duration-500 flex size-full items-center justify-center absolute inset-0`}
>
{content}
</div>
);
})}
</div>
<button
onClick={goToNext}
className="absolute right-0 translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
›
</button>
</div>
<div className="carousel-text mt-4 w-[400px] text-center">
<h3 className="text-lg font-semibold">{slides[currentIndex].title}</h3>
<p className="text-gray-600">{slides[currentIndex].description}</p>
</div>
<div className="carousel-indicators mt-2 flex space-x-1">
{slides.map((_, index) => (
<span
key={index}
onClick={() => setCurrentIndex(index)}
className={`block size-2 cursor-pointer rounded-full ${
index === currentIndex ? 'bg-black' : 'bg-gray-300'
}`}
/>
))}
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('app')).render(
<Carousel
slides={[
{ content: 'Foo' },
{ content: 'Bar' },
{ content: 'Baz' },
{ content: 'Qux' },
]}
/>
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js" integrity="sha512-QVs8Lo43F9lSuBykadDb0oSXDL/BbZ588urWVCRwSIoewQv/Ewg1f84mK3U790bZ0FfhFa1YSQUmIhG+pIRKeg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js" integrity="sha512-6a1107rTlA4gYpgHAqbwLAtxmWipBdJFcq8y5S/aTge3Bp+VAklABm2LO+Kg51vOWR9JMZq1Ovjl5tpluNpTeQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.tailwindcss.com/3.4.5"></script>
<div id="app"></div>
However, when using the pagination controls to navigate between non-sequential slides, we see the slide(s) between them also move across to the other side. This probably not desired, so to work around this, we can keep non-active slides hidden. This allows them to be moved around without being seen. We can do this by applying visibility: hidden
on inactive slides:
const { useState } = React;
const Carousel = ({ slides }) => {
const [currentIndex, setCurrentIndex] = useState(0);
const goToPrevious = () => {
setCurrentIndex((i) => (i === 0 ? slides.length - 1 : i - 1));
};
const goToNext = () => {
setCurrentIndex((i) => (i === slides.length - 1 ? 0 : i + 1));
};
return (
<div className="carousel-container flex flex-col items-center">
<div className="relative flex h-[250px] w-[400px] items-center justify-center">
<button
onClick={goToPrevious}
className="absolute left-0 -translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
‹
</button>
<div className="relative size-full overflow-hidden rounded-lg bg-gray-100">
{slides.map(({ content }, i) => {
let animateClass = '';
if (i < currentIndex) {
animateClass = '-translate-x-full invisible';
} else if (i > currentIndex) {
animateClass = 'translate-x-full invisible';
}
return (
<div
key={i}
className={`${animateClass} duration-500 flex size-full items-center justify-center absolute inset-0`}
>
{content}
</div>
);
})}
</div>
<button
onClick={goToNext}
className="absolute right-0 translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
›
</button>
</div>
<div className="carousel-text mt-4 w-[400px] text-center">
<h3 className="text-lg font-semibold">{slides[currentIndex].title}</h3>
<p className="text-gray-600">{slides[currentIndex].description}</p>
</div>
<div className="carousel-indicators mt-2 flex space-x-1">
{slides.map((_, index) => (
<span
key={index}
onClick={() => setCurrentIndex(index)}
className={`block size-2 cursor-pointer rounded-full ${
index === currentIndex ? 'bg-black' : 'bg-gray-300'
}`}
/>
))}
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('app')).render(
<Carousel
slides={[
{ content: 'Foo' },
{ content: 'Bar' },
{ content: 'Baz' },
{ content: 'Qux' },
]}
/>
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js" integrity="sha512-QVs8Lo43F9lSuBykadDb0oSXDL/BbZ588urWVCRwSIoewQv/Ewg1f84mK3U790bZ0FfhFa1YSQUmIhG+pIRKeg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js" integrity="sha512-6a1107rTlA4gYpgHAqbwLAtxmWipBdJFcq8y5S/aTge3Bp+VAklABm2LO+Kg51vOWR9JMZq1Ovjl5tpluNpTeQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.tailwindcss.com/3.4.5"></script>
<div id="app"></div>
Lastly, it does not look like a seamless loop when you move from last → first or first → last. For this to work, we'd need to position the first and last slides to the right and left respectively for each respective situation:
currentIndex = 0:
┌–––––┏━━━━━┓–––––┐
│ 3 ┃ 0 ┃ 1,2 │
└–––––┗━━━━━┛–––––┘
currentIndex = 3:
┌–––––┏━━━━━┓–––––┐
│ 1,2 ┃ 3 ┃ 0 │
└–––––┗━━━━━┛–––––┘
const { useState } = React;
const Carousel = ({ slides }) => {
const [currentIndex, setCurrentIndex] = useState(0);
const goToPrevious = () => {
setCurrentIndex((i) => (i === 0 ? slides.length - 1 : i - 1));
};
const goToNext = () => {
setCurrentIndex((i) => (i === slides.length - 1 ? 0 : i + 1));
};
return (
<div className="carousel-container flex flex-col items-center">
<div className="relative flex h-[250px] w-[400px] items-center justify-center">
<button
onClick={goToPrevious}
className="absolute left-0 -translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
‹
</button>
<div className="relative size-full overflow-hidden rounded-lg bg-gray-100">
{slides.map(({ content }, i) => {
let animateClass = '';
if (currentIndex === 0 && slides.length - 1 === i) {
animateClass = '-translate-x-full invisible';
} else if (currentIndex === slides.length - 1 && i === 0) {
animateClass = 'translate-x-full invisible';
} else if (i < currentIndex) {
animateClass = '-translate-x-full invisible';
} else if (i > currentIndex) {
animateClass = 'translate-x-full invisible';
}
return (
<div
key={i}
className={`${animateClass} duration-500 flex size-full items-center justify-center absolute inset-0`}
>
{content}
</div>
);
})}
</div>
<button
onClick={goToNext}
className="absolute right-0 translate-x-full px-4 py-2 text-lg font-bold text-gray-600"
>
›
</button>
</div>
<div className="carousel-text mt-4 w-[400px] text-center">
<h3 className="text-lg font-semibold">{slides[currentIndex].title}</h3>
<p className="text-gray-600">{slides[currentIndex].description}</p>
</div>
<div className="carousel-indicators mt-2 flex space-x-1">
{slides.map((_, index) => (
<span
key={index}
onClick={() => setCurrentIndex(index)}
className={`block size-2 cursor-pointer rounded-full ${
index === currentIndex ? 'bg-black' : 'bg-gray-300'
}`}
/>
))}
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('app')).render(
<Carousel
slides={[
{ content: 'Foo' },
{ content: 'Bar' },
{ content: 'Baz' },
{ content: 'Qux' },
]}
/>
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js" integrity="sha512-QVs8Lo43F9lSuBykadDb0oSXDL/BbZ588urWVCRwSIoewQv/Ewg1f84mK3U790bZ0FfhFa1YSQUmIhG+pIRKeg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js" integrity="sha512-6a1107rTlA4gYpgHAqbwLAtxmWipBdJFcq8y5S/aTge3Bp+VAklABm2LO+Kg51vOWR9JMZq1Ovjl5tpluNpTeQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.tailwindcss.com/3.4.5"></script>
<div id="app"></div>