Tutorials 5 min read

How to create a carousel with Tailwind CSS: JavaScript and Alpine.js

Build an accessible carousel with previous and next buttons using Tailwind CSS, two ways: vanilla JavaScript smooth scrolling or Alpine.js state in x-data.

Yes, a carousel. We are building one with Tailwind CSS, twice: first with vanilla JavaScript, then with Alpine.js, so you can pick whichever fits your stack.

A carousel is a slider that displays a series of images or content in a continuous loop. It’s a handy way to showcase several items in the space of one, letting users flick through them with previous and next buttons.

Use cases

  • Product listings: show a whole collection without taking over the page.
  • Blog posts and news: rotate featured articles on the homepage.
  • Image and video galleries: let users browse media without leaving the page.
  • Testimonials: cycle through customer quotes one at a time.

The markup

  • id="carousel": identifies the carousel in JavaScript.
  • aria-labelledby="carousel-label" role="region" tabindex="0": the ARIA attributes that make the carousel accessible.
  • <button id="prevButton" tabindex="0"> and <button id="nextButton" tabindex="0">: navigate to the previous and next slides.
  • <ul role="listbox" id="slider"> with snap-mandatory snap-x: the scroll-snapping list that holds the slides.
  • role="option": each slide; the content goes inside.
html
<div id="carousel">
  <div aria-labelledby="carousel-label" role="region" tabindex="0">
    <div>
      <button id="prevButton" tabindex="0">
        <span aria-hidden="true">&larr;</span>
        <span class="sr-only">Skip to previous slide page</span>
      </button>
      <button id="nextButton" tabindex="0">
        <span aria-hidden="true">&rarr;</span>
        <span class="sr-only">Skip to next slide page</span>
      </button>
    </div>
    <ul
      class=" snap-mandatory snap-x"
      aria-labelledby="carousel-content-label"
      role="listbox"
      tabindex="0"
      id="slider"
    >
      <li role="option">
        <!--- Slide content goes here -->
      </li>
    </ul>
  </div>
</div>

The script

  • document.addEventListener("DOMContentLoaded", () => {: waits for the page, then grabs slider, prevButton and nextButton, and sets skip = 1, the number of slides to move per click.
  • updateButtonState(): checks slider.scrollLeft to know if we’re at the beginning or the end, then toggles opacity-50, aria-disabled and tabindex on the matching button.
  • scrollTo(strategy): reads the current scroll position and the width of the first slide, then smooth-scrolls to wherever the strategy function says.
  • next() and prev(): the two strategies, moving one slide width forward or back.
  • The click listeners wire up both buttons, and the scroll listener keeps the button state in sync.
  • updateButtonState();: runs once at load so the buttons start in the right state.
js
document.addEventListener("DOMContentLoaded", () => {
  const slider = document.getElementById("slider");
  const prevButton = document.getElementById("prevButton");
  const nextButton = document.getElementById("nextButton");
  let skip = 1;

  const updateButtonState = () => {
    const atBeginning = slider.scrollLeft === 0;
    const atEnd = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth;
    prevButton.classList.toggle("opacity-50", atBeginning);
    prevButton.setAttribute("aria-disabled", atBeginning);
    prevButton.setAttribute("tabindex", atBeginning ? "-1" : "0");
    nextButton.classList.toggle("opacity-50", atEnd);
    nextButton.setAttribute("aria-disabled", atEnd);
    nextButton.setAttribute("tabindex", atEnd ? "-1" : "0");
  };

  const scrollTo = (strategy) => {
    let current = slider.scrollLeft;
    let offset = slider.firstElementChild.getBoundingClientRect().width;
    slider.scrollTo({
      left: strategy(current, offset),
      behavior: "smooth",
    });
  };

  const next = () => scrollTo((current, offset) => current + offset * skip);
  const prev = () => scrollTo((current, offset) => current - offset * skip);

  prevButton.addEventListener("click", prev);
  nextButton.addEventListener("click", next);
  slider.addEventListener("scroll", updateButtonState);

  updateButtonState();
});

The Alpine.js version

Same component, no separate script: the state and the scrolling logic live in x-data.

  • x-data="{ skip: 1, atBeginning: false, atEnd: false, ... }": stores the state of the carousel along with its methods.
  • next() and prev(): move one slide width forward or back through the shared to(strategy) helper.
  • to(strategy): grabs the slider via $refs.slider and smooth-scrolls it, same math as the vanilla version.
  • focusableWhenVisible: uses x-intersect to remove tabindex from slides while they are visible and set it to -1 when they leave the view.
  • disableNextAndPreviousButtons: uses x-intersect thresholds on the first and last slides to flip atBeginning and atEnd.
  • x-on:keydown.left="prev" and x-on:keydown.right="next": keyboard navigation.
  • :class, :aria-disabled and :tabindex on the buttons: react to atBeginning and atEnd.

Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.

html
<div
  x-data="{
        skip: 1,
        atBeginning: false,
        atEnd: false,
        next() {
            this.to((current, offset) => current + (offset * this.skip))
        },
        prev() {
            this.to((current, offset) => current - (offset * this.skip))
        },
        to(strategy) {
            let slider = this.$refs.slider
            let current = slider.scrollLeft
            let offset = slider.firstElementChild.getBoundingClientRect().width
            slider.scrollTo({ left: strategy(current, offset), behavior: 'smooth' })
        },
        focusableWhenVisible: {
            'x-intersect:enter'() {
                this.$el.removeAttribute('tabindex')
            },
            'x-intersect:leave'() {
                this.$el.setAttribute('tabindex', '-1')
            },
        },
        disableNextAndPreviousButtons: {
            'x-intersect:enter.threshold.05'() {
                let slideEls = this.$el.parentElement.children
                // If this is the first slide.
                if (slideEls[0] === this.$el) {
                    this.atBeginning = true
                // If this is the last slide.
                } else if (slideEls[slideEls.length-1] === this.$el) {
                    this.atEnd = true
                }
            },
            'x-intersect:leave.threshold.05'() {
                let slideEls = this.$el.parentElement.children
                // If this is the first slide.
                if (slideEls[0] === this.$el) {
                    this.atBeginning = false
                // If this is the last slide.
                } else if (slideEls[slideEls.length-1] === this.$el) {
                    this.atEnd = false
                }
            },
        },
    }"
>
  <div
    aria-labelledby="carousel-label"
    role="region"
    tabindex="0"
    x-on:keydown.left="prev"
    x-on:keydown.right="next"
  >
    <div>
      <button
        :class="{ 'opacity-50 ': atBeginning }"
        :aria-disabled="atBeginning"
        :tabindex="atEnd ? -1 : 0"
        x-on:click="prev"
        tabindex="0"
      >
        <span aria-hidden="true" class="mx-auto"> &larr; </span>
      </button>
      <button
        :class="{ 'opacity-50 ': atEnd }"
        :aria-disabled="atEnd"
        :tabindex="atEnd ? -1 : 0"
        x-on:click="next"
        tabindex="0"
      >
        <span aria-hidden="true" class="mx-auto"> &rarr; </span>
      </button>
    </div>
    <ul
      role="listbox"
      aria-labelledby="carousel-content-label"
      tabindex="0"
      x-ref="slider"
    >
      <li role="option" x-bind="disableNextAndPreviousButtons">
        <!--  Slide content goes here -->
      </li>
      <!-- More slides -->
    </ul>
  </div>
</div>

You can try the Alpine version separately: live demo and source code.

Which one should you use?

If Alpine and its Intersect plugin are already in your project, the x-data version keeps everything in the markup, keyboard support included. If not, the vanilla script is compact and dependency free.

Conclusion

This is a simple carousel that can be used for any type of content, such as a product listing, blog posts, news articles, or image galleries. Remember to make it as accessible as possible, and you’re good to go!

Hope you enjoyed this tutorial and have a great day!

/Michael Andreuzza