Tutorials 5 min read

How to Create an Image Carousel with Slide Number Counter Using Tailwind CSS and Alpine.js

Build an interactive image carousel with slide number counter using Alpine.js and Tailwind CSS. Learn how to create smooth transitions, navigate between images, and display slide indicators—perfect for portfolios, galleries, and hero sections.

Image carousels are a popular way to showcase multiple images in a compact, interactive format. In this tutorial, we’ll build a fully functional carousel with smooth transitions, navigation buttons, a slide counter, and dot indicators—all using Alpine.js and Tailwind CSS.

What You’ll Learn

  • How to set up Alpine.js state management for carousel functionality
  • Creating smooth opacity transitions between slides
  • Building navigation buttons (previous/next)
  • Adding a slide counter display
  • Implementing dot indicators for direct navigation
  • Best practices for accessibility

The HTML & Tailwind Structure

Here’s the complete carousel code.

Breaking Down the Code

html
<div class="relative w-full overflow-hidden bg-zinc-100 rounded-2xl"></div>
  • relative: Establishes a positioning context for absolute elements inside
  • overflow-hidden: Clips any content that extends beyond the carousel bounds
  • rounded-2xl: Adds rounded corners for a modern look

2. Slides Container

html
<div class="relative w-full h-96 md:h-[500px]"></div>
  • Sets the carousel height (h-96 on mobile, h-[500px] on larger screens)
  • relative positioning allows child images to be positioned absolutely

3. Individual Slides

html
<template x-for="(slide, index) in slides" :key="index">
  <div
    :class="currentSlide === index ? 'opacity-100' : 'opacity-0'"
    class="absolute inset-0 transition-opacity duration-500 ease-in-out"
  ></div
></template>
  • x-for: Alpine loops through each slide
  • :class binding: Shows the current slide at opacity-100, hides others at opacity-0
  • transition-opacity duration-500: Creates a smooth fade effect over 500ms
  • inset-0: Positions the image to fill the entire container

4. Image with Dynamic Sources

html
<img
  :src="slide === 0 ? 'url1' : slide === 1 ? 'url2' : 'url3'"
  class="w-full h-full object-cover"
/>
  • Uses ternary operators to load different images based on the slide index
  • object-cover: Ensures the image fills the container without distortion

5. Navigation Buttons

html
<div class="absolute left-0 top-1/2 -translate-y-1/2 z-20"></div>
  • absolute left-0 top-1/2: Positions the button on the left edge, vertically centered
  • -translate-y-1/2: Offsets it by -50% to perfectly center it
  • z-20: Ensures buttons appear above the images
  • @click="prev()" and @click="next()": Triggers navigation functions

6. Slide Counter

html
<div
  class="absolute top-4 right-4 z-20 px-3 py-2 bg-foreground/50 text-background text-sm font-medium rounded-full"
>
  <span x-text="currentSlide + 1"></span> / <span x-text="slides"></span>
</div>
  • Displays the current slide number and total slides
  • bg-foreground/50: Semi-transparent black background
  • rounded-full: Pill-shaped container
  • currentSlide + 1: Shows 1-based indexing (1, 2, 3…) instead of 0-based

7. Dot Indicators

html
<template x-for="(slide, index) in slides" :key="index">
  <button
    @click="goToSlide(index)"
    :class="currentSlide === index ? 'bg-accent-500' : 'bg-zinc-300 hover:bg-zinc-400'"
    class="w-3 h-3 rounded-full transition-colors duration-200"
  ></button
></template>
  • Loops through slides creating a dot for each
  • Active dot: bg-accent-500 (highlighted color)
  • Inactive dots: bg-zinc-300 with hover state
  • @click="goToSlide(index)": Jump directly to any slide
  • rounded-full: Creates perfect circles

Alpine.js State Management

To make this work, you need Alpine.js data:

javascript
x-data="{
  currentSlide: 0,
  slides: 3,
  next() {
    this.currentSlide = (this.currentSlide + 1) % this.slides;
  },
  prev() {
    this.currentSlide = (this.currentSlide - 1 + this.slides) % this.slides;
  },
  goToSlide(index) {
    this.currentSlide = index;
  }
}"
  • currentSlide: Tracks which slide is being displayed
  • slides: Total number of slides (3 in this example)
  • next(): Moves to the next slide, wrapping around to the first if at the end
  • prev(): Moves to the previous slide, wrapping to the last if at the beginning
  • goToSlide(index): Jumps directly to a specific slide

Key Features Explained

Smooth Transitions: The transition-opacity duration-500 ease-in-out class creates a fade effect over 500 milliseconds.

Responsive Height: Uses Tailwind’s responsive classes (h-96 md:h-[500px]) to adjust carousel height on different screen sizes.

Accessibility: Includes aria-label, aria-current, and proper semantic HTML for screen readers.

Circular Navigation: The modulo operator (%) ensures the carousel loops—going forward from the last slide returns to the first, and vice versa.

Customization Ideas

  • Change duration-500 to duration-300 for faster transitions
  • Modify image URLs to use your own images or dynamic data
  • Add auto-play by using setInterval() to call next() automatically
  • Replace opacity transitions with CSS transforms for slide animations
  • Add keyboard navigation with arrow keys
  • Include swipe gestures for mobile devices

This carousel is production-ready and works great for portfolios, product showcases, testimonials, and hero sections. Enjoy!

Complete Code

html
<!-- Carousel Container -->
<div class="relative w-full overflow-hidden bg-zinc-100 rounded-2xl">
  <!-- Slides -->
  <div class="relative w-full h-96 md:h-[500px]">
    <template x-for="(slide, index) in slides" :key="index">
      <div
        :class="currentSlide === index ? 'opacity-100' : 'opacity-0'"
        class="absolute inset-0 transition-opacity duration-500 ease-in-out"
      >
        <img
          :src="slide === 0 ? 'https://images.unsplash.com/photo-1570129477492-45c003edd2be?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=2070' : slide === 1 ? 'https://plus.unsplash.com/premium_photo-1733760125497-cd51a05afc6c?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=2071' : 'https://plus.unsplash.com/premium_photo-1733760125031-62a1611f02db?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=2071'"
          :alt="slide === 0 ? 'Blue Aurora' : slide === 1 ? 'Blue Blob' : 'Gradient Animation'"
          class="w-full h-full object-cover"
        />
      </div>
    </template>
  </div>

  <!-- Navigation Buttons -->
  <div class="absolute left-0 top-1/2 -translate-y-1/2 z-20">
    <button
      variant="muted"
      size="md"
      iconOnly
      @click="prev()"
      aria-label="Previous slide"
      class="rounded-r-lg rounded-l-none"
    >
      <fragment slot="icon">
        <svg
          class="w-5 h-5"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="2"
            d="M15 19l-7-7 7-7"
          ></path>
        </svg>
      </fragment>
    </button>
  </div>

  <div class="absolute right-0 top-1/2 -translate-y-1/2 z-20">
    <button
      variant="muted"
      size="md"
      iconOnly
      @click="next()"
      aria-label="Next slide"
      class="rounded-l-lg rounded-r-none"
    >
      <fragment slot="icon">
        <svg
          class="w-5 h-5"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="2"
            d="M9 5l7 7-7 7"
          ></path>
        </svg>
      </fragment>
    </button>
  </div>

  <!-- Slide Counter -->
  <div
    class="absolute top-4 right-4 z-20 px-3 py-2 bg-foreground/50 text-background text-sm font-medium rounded-full"
  >
    <span x-text="currentSlide + 1"></span> / <span x-text="slides"></span>
  </div>
</div>

<!-- Dot Indicators -->
<div class="flex justify-center gap-2 mt-6">
  <template x-for="(slide, index) in slides" :key="index">
    <button
      @click="goToSlide(index)"
      :class="currentSlide === index ? 'bg-accent-500' : 'bg-zinc-300 hover:bg-zinc-400'"
      class="w-3 h-3 rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2"
      :aria-label="`Go to slide ${index + 1}`"
      :aria-current="currentSlide === index"
    ></button>
  </template>
</div>

/Michael Andreuzza