Create a Carousel with Progress Indicators using Tailwind and Next.js
Published: 2024-01-09 11:25:32

####**[Live Demo](https://cruip-tutorials-next.vercel.app/progress-slider/) / [Download](https://github.com/cruip/cruip-tutorials-next/blob/main/components/progress-slider.tsx)**

In the first part of this tutorial, we showed you [how to build a modular carousel component with progress indicators using Alpine.js](https://cruip.com/create-a-carousel-with-progress-indicators-using-tailwind-and-alpinejs/). 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:

```jsx
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](https://headlessui.com/react/transition) library created by the makers of Tailwind CSS.

```jsx
'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:

```jsx
'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](https://cruip.com/create-a-carousel-with-progress-indicators-using-tailwind-and-alpinejs/#integrating-autoplay):

```jsx
'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 the `active` 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 the `active` 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:

```jsx
'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:

```jsx
'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:

```jsx
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](https://cruip.com) if you're looking for similar high-quality components, pre-built, and professionally crafted by us.

*   [Create a Carousel with Progress Indicators using Tailwind and Alpine.js](https://cruip.com/create-a-carousel-with-progress-indicators-using-tailwind-and-alpinejs/)
*   [Create a Carousel with Progress Indicators using Tailwind and Vue](https://cruip.com/create-a-carousel-with-progress-indicators-using-tailwind-and-vue/)