DEV Community

Cover image for Create a Carousel with Progress Indicators using Tailwind and Next.js
Cruip
Cruip

Posted on • Originally published at cruip.com

Create a Carousel with Progress Indicators using Tailwind and Next.js

Live Demo / Download

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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:

'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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)