Create a Carousel with Progress Indicators using Tailwind and Next.js
In the first part of this tutorial, we showed you how to build a modular carousel component with progress indicators using Alpine.js. Now, we’re going to do something very similar but with React. We’ll create a Next.js component with full TypeScript support using the new App Router introduced in version 13.
Let’s start by importing the images we’ll be using into the public
directory of our app. Then, we need to create a file named progress-slider.tsx
. In this file, we’ll define an array called items
containing the data required to populate our carousel. We’ll be reusing the HTML structure from the Alpine.js component created previously. Here’s our starting point:
import Image from 'next/image'
import SilderImg01 from '@/public/ps-image-01.png'
import SilderImg02 from '@/public/ps-image-02.png'
import SilderImg03 from '@/public/ps-image-03.png'
import SilderImg04 from '@/public/ps-image-04.png'
import SilderIcon01 from '@/public/ps-icon-01.svg'
import SilderIcon02 from '@/public/ps-icon-02.svg'
import SilderIcon03 from '@/public/ps-icon-03.svg'
import SilderIcon04 from '@/public/ps-icon-04.svg'
export default function ProgressSlider() {
const items = [
{
img: SilderImg01,
desc: 'Omnichannel',
buttonIcon: SilderIcon01,
},
{
img: SilderImg02,
desc: 'Multilingual',
buttonIcon: SilderIcon02,
},
{
img: SilderImg03,
desc: 'Interpolate',
buttonIcon: SilderIcon03,
},
{
img: SilderImg04,
desc: 'Enriched',
buttonIcon: SilderIcon04,
},
]
return (
<div className="w-full max-w-5xl mx-auto text-center">
{/* Item image */}
<div className="transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col">
{items.map((item, index) => (
<Image className="rounded-xl" src={item.img} width={1024} height={576} alt={item.desc} />
))}
</div>
</div>
{/* Buttons */}
<div className="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
{items.map((item, index) => (
<button
key={index}
className="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
>
<span className="text-center flex flex-col items-center">
<span className="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
<Image src={item.buttonIcon} alt={item.desc} />
</span>
<span className="block text-sm font-medium text-slate-900 mb-2">{item.desc}</span>
<span className="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuenow={0}>
<span className="absolute inset-0 bg-indigo-500 rounded-[inherit]" style={{ width: '0%' }}></span>
</span>
</span>
</button>
))}
</div>
</div>
)
}
In the code above, we used the map
function to loop through the items
array and render an Image
component along with a corresponding button for each item. The results is a stack of images placed one above the other, with a set of buttons at the bottom.
Define the active element and add transitions
The next step is to add transitions, which we’ll integrate using the Transition
component from the Headless UI library created by the makers of Tailwind CSS.
'use client'
import { useState } from 'react'
import { Transition } from '@headlessui/react'
import Image from 'next/image'
{/* ... image imports ... */}
export default function ProgressSlider() {
const [active, setActive] = useState<number>(0)
const items = [ {/* ... items ... */} ]
return (
<div className="w-full max-w-5xl mx-auto text-center">
{/* Item image */}
<div className="transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col">
{items.map((item, index) => (
<Transition
key={index}
show={active === index}
enter="transition ease-in-out duration-500 delay-200 order-first"
enterFrom="opacity-0 scale-105"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-300 absolute"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Image className="rounded-xl" src={item.img} width={1024} height={576} alt={item.desc} />
</Transition>
))}
</div>
</div>
{/* Buttons */}
<div className="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
{items.map((item, index) => (
<button
key={index}
className="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
onClick={() => { setActive(index) }}
>
<span className={`text-center flex flex-col items-center ${active === index ? '' : 'opacity-50 group-hover:opacity-100 group-focus:opacity-100 transition-opacity'}`}>
<span className="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
<Image src={item.buttonIcon} alt={item.desc} />
</span>
<span className="block text-sm font-medium text-slate-900 mb-2">{item.desc}</span>
<span className="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuenow={0}>
<span className="absolute inset-0 bg-indigo-500 rounded-[inherit]" style={{ width: '0%' }}></span>
</span>
</span>
</button>
))}
</div>
</div>
)
}
We added a new state called active
to determine which element is currently active. Additionally, we included an onClick
event for the button. So, when the button is clicked, the index of the corresponding element becomes the value of the active
state.
Finally, we enclosed the Image
element within the Transition
component and used the show
property to define which element should be displayed. The properties enter
, enterFrom
, enterTo
, leave
, leaveFrom
, and leaveTo
define the transitions to be applied when an element is shown or hidden.
Transitions work but, due to the absolute positioning of the leaving image, the animation produces an annoying flickering effect. Let’s fix this by adding a new method:
'use client'
import { useState, useRef, useEffect } from 'react'
import { Transition } from '@headlessui/react'
import Image from 'next/image'
{/* ... image imports ... */}
export default function ProgressSlider() {
const itemsRef = useRef<HTMLDivElement>(null)
const [active, setActive] = useState<number>(0)
const items = [ {/* ... items ... */} ]
const heightFix = () => {
if (itemsRef.current && itemsRef.current.parentElement) itemsRef.current.parentElement.style.height = `${itemsRef.current.clientHeight}px`
}
useEffect(() => {
heightFix()
}, [])
return (
<div className="w-full max-w-5xl mx-auto text-center">
{/* Item image */}
<div className="transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col" ref={itemsRef}>
{items.map((item, index) => (
<Transition
key={index}
show={active === index}
enter="transition ease-in-out duration-500 delay-200 order-first"
enterFrom="opacity-0 scale-105"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-300 absolute"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
beforeEnter={() => heightFix()}
>
<Image className="rounded-xl" src={item.img} width={1024} height={576} alt={item.desc} />
</Transition>
))}
</div>
</div>
{/* Buttons */}
<div className="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
{items.map((item, index) => (
<button
key={index}
className="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
onClick={() => { setActive(index) }}
>
<span className={`text-center flex flex-col items-center ${active === index ? '' : 'opacity-50 group-hover:opacity-100 group-focus:opacity-100 transition-opacity'}`}>
<span className="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
<Image src={item.buttonIcon} alt={item.desc} />
</span>
<span className="block text-sm font-medium text-slate-900 mb-2">{item.desc}</span>
<span className="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuenow={0}>
<span className="absolute inset-0 bg-indigo-500 rounded-[inherit]" style={{ width: '0%' }}></span>
</span>
</span>
</button>
))}
</div>
</div>
)
}
The heightFix
method is invoked not only when the component is mounted, but also whenever the transition occurs, using the beforeEnter
callback provided by the Transition
component. This method calculates the height of the element that contains the image and applies it to its parent element, effectively resolving the annoying flickering effect.
Make the carousel autorotate
So far, we’ve created a carousel that allows navigating through images by clicking buttons. Now, let’s add an autorotate feature that will automatically switch the image every 5 seconds. For that, we can reuse a portion of the code we wrote for the previous tutorial:
'use client'
import { useState, useRef, useEffect } from 'react'
import { Transition } from '@headlessui/react'
import Image from 'next/image'
{/* ... image imports ... */}
export default function ProgressSlider() {
const duration: number = 5000
const itemsRef = useRef<HTMLDivElement>(null)
const frame = useRef<number>(0)
const firstFrameTime = useRef(performance.now())
const [active, setActive] = useState<number>(0)
const items = [ {/* ... items ... */} ]
useEffect(() => {
firstFrameTime.current = performance.now()
frame.current = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(frame.current)
}
}, [active])
const animate = (now: number) => {
let timeFraction = (now - firstFrameTime.current) / duration
if (timeFraction <= 1) {
frame.current = requestAnimationFrame(animate)
} else {
timeFraction = 1
setActive((active + 1) % items.length)
}
}
const heightFix = () => {
if (itemsRef.current && itemsRef.current.parentElement) itemsRef.current.parentElement.style.height = `${itemsRef.current.clientHeight}px`
}
useEffect(() => {
heightFix()
}, [])
return (
<div className="w-full max-w-5xl mx-auto text-center">
{/* Item image */}
<div className="transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col" ref={itemsRef}>
{items.map((item, index) => (
<Transition
key={index}
show={active === index}
enter="transition ease-in-out duration-500 delay-200 order-first"
enterFrom="opacity-0 scale-105"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-300 absolute"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
beforeEnter={() => heightFix()}
>
<Image className="rounded-xl" src={item.img} width={1024} height={576} alt={item.desc} />
</Transition>
))}
</div>
</div>
{/* Buttons */}
<div className="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
{items.map((item, index) => (
<button
key={index}
className="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
onClick={() => { setActive(index) }}
>
<span className={`text-center flex flex-col items-center ${active === index ? '' : 'opacity-50 group-hover:opacity-100 group-focus:opacity-100 transition-opacity'}`}>
<span className="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
<Image src={item.buttonIcon} alt={item.desc} />
</span>
<span className="block text-sm font-medium text-slate-900 mb-2">{item.desc}</span>
<span className="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuenow={0}>
<span className="absolute inset-0 bg-indigo-500 rounded-[inherit]" style={{ width: '0%' }}></span>
</span>
</span>
</button>
))}
</div>
</div>
)
}
In the code above:
-
duration
is the time in milliseconds that each image will be displayed before switching to the next one. -
frame
is a reference to the animation frame that we will use to animate the images. -
firstFrameTime
is a reference to the time when the animation started. -
useEffect
is used to start the animation when the component is mounted and when theactive
state changes. -
animate
is the function that will be called on each animation frame. It calculates the time fraction and, when it reaches 1, it updates theactive
state.
Integrate the progress indicator
The last step to complete our component is adding a progress indicator to each button. Let’s see how to integrate it:
'use client'
import { useState, useRef, useEffect } from 'react'
import { Transition } from '@headlessui/react'
import Image from 'next/image'
{/* ... image imports ... */}
export default function ProgressSlider() {
const duration: number = 5000
const itemsRef = useRef<HTMLDivElement>(null)
const frame = useRef<number>(0)
const firstFrameTime = useRef(performance.now())
const [active, setActive] = useState<number>(0)
const [progress, setProgress] = useState<number>(0)
const items = [ {/* ... items ... */} ]
useEffect(() => {
firstFrameTime.current = performance.now()
frame.current = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(frame.current)
}
}, [active])
const animate = (now: number) => {
let timeFraction = (now - firstFrameTime.current) / duration
if (timeFraction <= 1) {
setProgress(timeFraction * 100)
frame.current = requestAnimationFrame(animate)
} else {
timeFraction = 1
setProgress(0)
setActive((active + 1) % items.length)
}
}
const heightFix = () => {
if (itemsRef.current && itemsRef.current.parentElement) itemsRef.current.parentElement.style.height = `${itemsRef.current.clientHeight}px`
}
useEffect(() => {
heightFix()
}, [])
return (
<div className="w-full max-w-5xl mx-auto text-center">
{/* Item image */}
<div className="transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col" ref={itemsRef}>
{items.map((item, index) => (
<Transition
key={index}
show={active === index}
enter="transition ease-in-out duration-500 delay-200 order-first"
enterFrom="opacity-0 scale-105"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-300 absolute"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
beforeEnter={() => heightFix()}
>
<Image className="rounded-xl" src={item.img} width={1024} height={576} alt={item.desc} />
</Transition>
))}
</div>
</div>
{/* Buttons */}
<div className="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
{items.map((item, index) => (
<button
key={index}
className="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
onClick={() => { setActive(index); setProgress(0) }}
>
<span className={`text-center flex flex-col items-center ${active === index ? '' : 'opacity-50 group-hover:opacity-100 group-focus:opacity-100 transition-opacity'}`}>
<span className="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
<Image src={item.buttonIcon} alt={item.desc} />
</span>
<span className="block text-sm font-medium text-slate-900 mb-2">{item.desc}</span>
<span className="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuenow={active === index ? progress : 0}>
<span className="absolute inset-0 bg-indigo-500 rounded-[inherit]" style={{ width: active === index ? `${progress}%` : '0%' }}></span>
</span>
</span>
</button>
))}
</div>
</div>
)
}
We added a new state, progress
, that gets updated every time the animate
function is called. With this value, we can determine the width of the progress bar and set the value of the aria-valuenow
property of the containing element.
With this, the carousel component is now fully functional. It works perfectly if you only plan to use it once in your app. However, if you want to make it reusable and pass data to be displayed as props, we will need to make a small modification.
Make a reusable component
To make the component reusable, we need to define an interface
for the data we want to pass as props, and include the items
object within the parentheses of the functional component:
'use client'
import { useState, useRef, useEffect } from 'react'
import Image, { StaticImageData } from 'next/image'
import { Transition } from '@headlessui/react'
interface Item {
img: StaticImageData
desc: string
buttonIcon: StaticImageData
}
export default function ProgressSlider({ items }: { items: Item[] }) {
const duration: number = 5000
const itemsRef = useRef<HTMLDivElement>(null)
const frame = useRef<number>(0)
const firstFrameTime = useRef(performance.now())
const [active, setActive] = useState<number>(0)
const [progress, setProgress] = useState<number>(0)
useEffect(() => {
firstFrameTime.current = performance.now()
frame.current = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(frame.current)
}
}, [active])
const animate = (now: number) => {
let timeFraction = (now - firstFrameTime.current) / duration
if (timeFraction <= 1) {
setProgress(timeFraction * 100)
frame.current = requestAnimationFrame(animate)
} else {
timeFraction = 1
setProgress(0)
setActive((active + 1) % items.length)
}
}
const heightFix = () => {
if (itemsRef.current && itemsRef.current.parentElement) itemsRef.current.parentElement.style.height = `${itemsRef.current.clientHeight}px`
}
useEffect(() => {
heightFix()
}, [])
return (
<div className="w-full max-w-5xl mx-auto text-center">
{/* Item image */}
<div className="transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col" ref={itemsRef}>
{items.map((item, index) => (
<Transition
key={index}
show={active === index}
enter="transition ease-in-out duration-500 delay-200 order-first"
enterFrom="opacity-0 scale-105"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-300 absolute"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
beforeEnter={() => heightFix()}
>
<Image className="rounded-xl" src={item.img} width={1024} height={576} alt={item.desc} />
</Transition>
))}
</div>
</div>
{/* Buttons */}
<div className="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
{items.map((item, index) => (
<button
key={index}
className="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
onClick={() => { setActive(index); setProgress(0) }}
>
<span className={`text-center flex flex-col items-center ${active === index ? '' : 'opacity-50 group-hover:opacity-100 group-focus:opacity-100 transition-opacity'}`}>
<span className="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
<Image src={item.buttonIcon} alt={item.desc} />
</span>
<span className="block text-sm font-medium text-slate-900 mb-2">{item.desc}</span>
<span className="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuenow={active === index ? progress : 0}>
<span className="absolute inset-0 bg-indigo-500 rounded-[inherit]" style={{ width: active === index ? `${progress}%` : '0%' }}></span>
</span>
</span>
</button>
))}
</div>
</div>
)
}
Now, we can pass the items
array as props to our component from the parent component, which in our case is in the page.tsx
file:
export const metadata = {
title: 'Slider with Progress Indicator - Cruip Tutorials',
description: 'Page description',
}
import SilderImg01 from '@/public/ps-image-01.png'
import SilderImg02 from '@/public/ps-image-02.png'
import SilderImg03 from '@/public/ps-image-03.png'
import SilderImg04 from '@/public/ps-image-04.png'
import SilderIcon01 from '@/public/ps-icon-01.svg'
import SilderIcon02 from '@/public/ps-icon-02.svg'
import SilderIcon03 from '@/public/ps-icon-03.svg'
import SilderIcon04 from '@/public/ps-icon-04.svg'
import ProgressSlider from '@/components/progress-slider'
export default function ProgressSliderPage() {
const items = [
{
img: SilderImg01,
desc: 'Omnichannel',
buttonIcon: SilderIcon01,
},
{
img: SilderImg02,
desc: 'Multilingual',
buttonIcon: SilderIcon02,
},
{
img: SilderImg03,
desc: 'Interpolate',
buttonIcon: SilderIcon03,
},
{
img: SilderImg04,
desc: 'Enriched',
buttonIcon: SilderIcon04,
},
]
return (
<main className="relative min-h-screen flex flex-col justify-center bg-slate-50 overflow-hidden">
<div className="w-full max-w-6xl mx-auto px-4 md:px-6 py-24">
<div className="flex justify-center">
<ProgressSlider items={items} />
</div>
</div>
</main>
)
}
Conclusions
In the second part of this tutorial, we’ve created a carousel with progress indicators using Tailwind CSS and Next.js. We’ve also learned how to make the component reusable across our entire application.
If you want to see how to build a similar component with Alpine.js or Next.js, I recommend checking out the links below. We also recommend checking out our Tailwind templates if you’re looking for similar high-quality components, pre-built, and professionally crafted by us.