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.
What is a carousel?
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">withsnap-mandatory snap-x: the scroll-snapping list that holds the slides.role="option": each slide; the content goes inside.
<div id="carousel">
<div aria-labelledby="carousel-label" role="region" tabindex="0">
<div>
<button id="prevButton" tabindex="0">
<span aria-hidden="true">←</span>
<span class="sr-only">Skip to previous slide page</span>
</button>
<button id="nextButton" tabindex="0">
<span aria-hidden="true">→</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 grabsslider,prevButtonandnextButton, and setsskip = 1, the number of slides to move per click.updateButtonState(): checksslider.scrollLeftto know if we’re at the beginning or the end, then togglesopacity-50,aria-disabledandtabindexon 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()andprev(): the two strategies, moving one slide width forward or back.- The click listeners wire up both buttons, and the
scrolllistener keeps the button state in sync. updateButtonState();: runs once at load so the buttons start in the right state.
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()andprev(): move one slide width forward or back through the sharedto(strategy)helper.to(strategy): grabs the slider via$refs.sliderand smooth-scrolls it, same math as the vanilla version.focusableWhenVisible: usesx-intersectto removetabindexfrom slides while they are visible and set it to-1when they leave the view.disableNextAndPreviousButtons: usesx-intersectthresholds on the first and last slides to flipatBeginningandatEnd.x-on:keydown.left="prev"andx-on:keydown.right="next": keyboard navigation.:class,:aria-disabledand:tabindexon the buttons: react toatBeginningandatEnd.
Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.
<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"> ← </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"> → </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