Editor’s note: This article was last updated by Rahul Chhodde on 14 March 2024 to incorporate advanced customization features to the image carousel, including pagination, as well as incorporating features to improve the accessibility of the carousel.
There is no doubt that carousels are essential components in web design. When you are limited in space but still want to display a lot of information, carousels are good tools for displaying groups of related content. Whether it’s for rendering a slideshow, a testimonials section, or showcasing a sequence of multiple messages, carousels are indispensable.
In this tutorial, we will build a multi-featured, accessible carousel with just HTML, CSS, and plain old vanilla JavaScript. The only prerequisite for this tutorial is a working knowledge of HTML, CSS, and, JavaScript.
Whenever there’s a need to implement a carousel on a website — be it for design purposes or aesthetic appeal — the first thought that comes to mind is using a ready-made third-party solution to save time.
While this approach seems practical and time-efficient, it requires careful consideration to avoid code bloat and unnecessary dependencies, either of which may slow down your website. If a third-party solution you rely on gets discontinued, you may end up with potentially insecure code that causes performance, security, and compatibility issues with your codebase.
Considering these trade-offs, opting to build your own carousel solution is a good choice. Without further ado, let’s start building our feature-rich and accessible carousel from the ground up. You may find the full source code for it in this GitHub repository.
The basic idea behind this carousel solution is to arrange a series of equally sized slides horizontally, potentially extending beyond the available viewport width. After hiding the overflowing slides to display only one slide at once, we can programmatically change their position as required on events like button clicks.
Keeping these points in mind, let’s plan the basic structure of our carousel component, which is pretty simple with only a couple of HTML elements:
To enhance accessibility and make our carousel useful for screen readers, we will add certain roles and ARIA attributes to the container elements mentioned above. The initial usage markup for such a component would look something like the following:
<div id="carousel-1" class="carousel" role="region" aria-labelledby="carousel-1-title" > <h2 id="carousel-1-title">...</h2> <div class="slide" role="tabpanel" aria-labelledby="carousel-1-slide-1-title" > <div class="slide-content"> <h3 id="carousel-1-slide-1-title" class="slide-caption">...</h3> <img src="..." alt="..." /> </div> </div> <div class="slide" ...>...</div> <!-- More slides ... --> </div>
To implement multiple carousels on the same page without compromising accessibility, we should ensure that all the identifiers (IDs) are unique and that the aria-labelledby
attribute corresponds to its related IDs.
All the other sub-components will be programmatically added later to our carousel component, including the next and previous buttons, as well as pagination to navigate between slides. The following figure explains the usage structure compared to the same structure modified by the DOM to meet carousel requirements:
Before we start writing JavaScript logic for our carousel, let’s first establish some major alignment and presentation CSS rules to shape the UI for our carousel. All the CSS styles discussed here go into the style.css
file of the project, which should be added to the webpages that will implement the carousel.
Providing a basic box-sizing
reset to size all elements appropriately is a must for every frontend web project. If your website already implements that, you may consider removing the box-sizing
bit to avoid duplication.
To ensure that media elements like images and videos size up according to the width of their parent container and don’t bleed out, we should set their maximum width to 100%:
/* Box-sizing reset */ *, *::before, *::after { box-sizing: border-box; } /* Basic body styles */ body { margin: 0; font: 1em/1.6 sans-serif; } /* Optimized media elements */ img, video, audio, ... { max-width: 100%; }
Given the expected DOM-manipulated markup structure, let’s write CSS styles for the carousel containers. In the manipulated markup, the .carousel-inner
element will take charge and hold the slides and the buttons responsible for triggering the previous and next slide movements.
Both the slide elements and the buttons should be absolute-positioned for alignment purposes, and slides should be placed horizontally one after the other using the CSS transform
property.
To have the .carousel-inner
control the flow of these elements, we need to set its position to relative
, give it some height, and clip all the elements bleeding outside of it using the overflow
CSS property:
.carousel-inner { overflow: hidden; position: relative; min-height: var(--carousel-height); }
Only one slide should be visible at once, meaning the slide element should make use of all the available width in the .carousel-inner
and be absolute-positioned for alignment purposes. At this point, we should also set up a transition for the transform property and add a suitable duration and transition function for this transition.
After that, each slide will appear stacked on top of the other; we will rearrange them later by applying the CSS transform
property individually with JavaScript.
Additionally, we may add a relatively positioned inner element inside the slide to hold its content and place the captions around its edges for alignment variety:
.slide { width: 100%; position: absolute; transition: transform 0.5s ease-in-out; } .slide-content { position: relative; z-index: 5000; }
Each slide in our carousel is represented by one image, which should fill it up like a cover and spread across the entire available width.
For better accessibility, we are defining each of our slides using the aria-labelledby
attribute and linking it to a piece of text we are using inside the slide. Decorating and aligning this text as a caption to our slide with the help of CSS positional properties is pretty straightforward:
.slide img { width: 100%; object-fit: cover; } .slide-caption { width: 100%; position: absolute; padding: 2em; left: 0; bottom: 0; }
We have two different appearances for buttons in the component: the previous-next slide controls and the paged navigation controls. We will first align the previous and next control buttons to the edges of .carousel-inner
:
.carousel-btn { ... } .carousel-btn--prev-next { position: absolute; top: 50%; transform: translateY(-50%); } .carousel-btn--prev { left: 2em; } .carousel-btn--next { right: 2em; }
The current structure of our carousel doesn’t make much sense at the moment. In this section, we’ll write functional JavaScript code to build our carousel component step-by-step and extend it further feature-by-feature.
To keep things simple, we are not going down the modular JavaScript development road. Instead, we’ll stick to plain old dynamically typed JavaScript.
Start with defining a function that takes configuration inputs for the carousel. Using these configuration details, it should build all the required functionalities, piece them together, and provide an interface to attach and detach multiple carousels as and when needed.
Let’s call the function JSCarousel
, which accepts the configuration as an object parameter, tweaks the carousel structure, manages its state, adds navigation, handles events, and more. Currently, it takes only two parametric properties that represent references to the carousel and slide elements. We will add more configuration options later to incorporate more features:
const JSCarousel = ({ carouselSelector, slideSelector }) => { // ... };
carouselSelector
: Required; takes in a selector for our carousel component and locates it in the DOMslideSelector
: Required; specifies the selection of slide elements inside carouselSelector
The first thing we should do inside this function is ensure that the carousel element and slides exist in the DOM. If they don’t, the function won’t go any further and it will exit after logging an error message to the console:
const JSCarousel = ({ ... }) => { // ... const carousel = document.querySelector(carouselSelector); if (!carousel) { console.error("Specify a valid selector for the carousel."); return null; } const slides = carousel.querySelectorAll(slideSelector); if (!slides.length) { console.error("Specify a valid selector for slides."); return null; } };
Next, declare some variables to manage the current slide state and hold references to the previous and next buttons, which will be added in the next few steps:
const JSCarousel = ({ ... }) => { let currentSlideIndex = 0; let prevButton, nextButton; };
As we continue building the carousel, different elements will be injected with multiple dynamic HTML elements with the help of the createElement
Web API method. To avoid this repetition, let’s write a utility function that takes an element tag, an object for element attributes, and its children as arguments. Based on these inputs, it should implement the creation logic and return the created element.
We can use this returned element later with utilities like appendChild
and insertBefore
to inject dynamic elements like buttons, wrapper elements, containers, etc.
const addElement = (tag, attributes, children) => { ... };
I haven’t covered the entire code for this utility to keep things centered on the carousel creation. You can find the complete code for it in this file.
Let’s assign a function to manage the carousel structure and call it tweakStructure
. Then, we’ll add an inner element to wrap all the slides of our carousel to separate the navigation from the slides.
Using the addElement
utility we created in the last segment, we provided the appropriate CSS class to the element before the first slide of our carousel:
const tweakStructure = () => { const carouselInner = addElement("div", { class: "carousel-inner", }); carousel.insertBefore(carouselInner, slides[0]); };
We should now move all our slide elements to this inner element to make it wrap all the slides and provide the behaviors we defined earlier in our CSS:
const tweakStructure = () => { // ... slides.forEach((slide) => { carouselInner.appendChild(slide); }); };
Let’s rearrange all our stacking slides one after the other in a horizontal fashion using CSS transform translations. We can loop through them and utilize their index to obtain the appropriate shift. Here’s the breakdown:
const tweakStructure = () => { // ... slides.forEach((slide, index) => { // ... slide.style.transform = `translateX(${index * 100}%)`; } };
To add previous and next slide controls to the carousel, we’ll use the addElement
method again and provide them with the appropriate CSS classes and aria-label
values. After that, we will append these two buttons to the .carousel-inner
element.
Note: You may consider adding these buttons to the main .carousel
element. I’m appending them to .carousel-inner
to achieve the perfect vertical alignment for the buttons in the finished output:
const tweakStructure = () => { // ... prevButton = addElement( "button", { class: "carousel-btn carousel-btn--prev-next carousel-btn--prev", "aria-label": "Previous Slide", }, "<" ); carouselInner.appendChild(prevButton); nextButton = addElement( "button", { class: "carousel-btn carousel-btn--prev-next carousel-btn--next", "aria-label": "Next Slide", }, ">" ); carouselInner.appendChild(nextButton); };
And that’s it for the tweakStructure
function. We’ll revisit it later to add more features including slide pagination.
The currentSlideIndex
value holds the index of the currently displayed slide and will be re-evaluated whenever the slide changes. This value will help us switch the slide position by manipulating each slide’s CSS transform translation style.
With the usual slide index and currentSlideIndex
value, we can establish a logic for every slide to adjust the slide’s position and use this function to trigger a carousel state change:
const adjustSlidePosition = () => { slides.forEach((slide, i) => { slide.style.transform = `translateX(${100 * (i - currentSlideIndex)}%)`; }); };
The carousel state is handled separately and uses sub-component-level state management functions like adjustSlidePosition
to trigger a change in the carousel state. It will use more similar functions later as we add more features to the carousel:
const updateCarouselState = () => { adjustSlidePosition(); };
Using the currentSlideIndex
value and the total number of slides, we can write a function to evaluate the index of the new slide. The calculation will be based on the value of the direction parameter of this function.
If the direction is set to next
and the slide exceeds the total number of slides, it goes back to the first slide using the following logic:
(currentSlideIndex + 1) % slides.length
Otherwise, it is considered to be moving in the backward (previous) direction, and if it goes below 0
, it moves to the last slide of the carousel:
(currentSlideIndex - 1 + slides.length) % slides.length;
This will rotate the slides properly, which is essential for a carousel:
const moveSlide = (direction) => { const newSlideIndex = direction === "next" ? (currentSlideIndex + 1) % slides.length : (currentSlideIndex - 1 + slides.length) % slides.length; currentSlideIndex = newSlideIndex; updateCarouselState(); };
It then updates the currentSlideIndex
to this newly evaluated index and calls the updateCarouselState
function to propagate this new currentSlideIndex
value to the previously defined state functions.
Whenever a previous or next button in the carousel is clicked, the moveSlide
should be fired with the appropriate direction argument. In scenarios like this, writing separate handlers is a much better practice than consuming the general utility like moveSlide
directly:
const handlePrevButtonClick = () => moveSlide("prev"); const handleNextButtonClick = () => moveSlide("next");
Now, let’s attach event listeners to the previous and next buttons and link them to their respective handler functions:
const attachEventListeners = () => { prevButton.addEventListener("click", handlePrevButtonClick); nextButton.addEventListener("click", handleNextButtonClick); };
It’s time to put all our structure and event listeners together in an initializing function:
const create = () => { tweakStructure(); attachEventListeners(); };
It is also good practice to define a cleaning function to detach the previously attached event listeners, which helps avoid memory leaks:
const destroy = () => { prevBtn.removeEventListener("click", handlePrevBtnClick); nextBtn.removeEventListener("click", handleNextBtnClick); };
Finally, let’s return the create
and destroy
functions in an object literal to employ them later with instances of JSCarousel
:
const JSCarousel = ({ ... }) => { // ... const addElement = () => { ... } const tweakStructure = () => { ... } const adjustSlidePosition = () => { ... } // ... const create = () => { ... } const destroy = () => { ... } return { create, destroy }; };
After creating an instance of JSCarousel
by providing the required config options, we can call the create
function on it to construct the carousel. When the user navigates away from the page, we will call the destroy
function to handle the cleanup:
// Initializing the first carousel const carousel1 = JSCarousel({ carouselSelector: '#carousel-1', slideSelector: '.slide', }); carousel1.create(); // Cleanup window.addEventListener('unload', () => { carousel1.destroy(); });
See the Pen
Simple JavaScript Carousel by Rahul (@_rahul)
on CodePen.
Implementing the page indication, pagination, or dot navigation to the carousel requires additional styling and JavaScript logic. Let’s cover them one by one.
The pagination should be a series of dot-like button inputs, which can be easily arranged using flexbox properties. I’ll leave styling these pagination buttons and their active state to you, which isn’t very complicated:
.carousel-pagination { display: flex; gap: .75em; ... } .carousel-pagination .carousel-btn { ... } .carousel-pagination .carousel-btn--active { ... }
If you don’t like dealing with CSS on your own, here’s the final CSS file for this project for you to refer to.
Let’s add enablePagination
to the JSCarousel
config object, and set it to true
, which means the carousel pagination is active by default:
const JSCarousel = ({ ... enablePagination = true, }) => { // ... };
Whenever a pagination button is clicked, it should update currentSlideIndex
to the index of the clicked button and trigger a carousel state update:
// Event handler for pagination button click event. const handlePaginationBtnClick = (index) => { currentSlideIndex = index; updateCarouselState(); };
The pagination container should act like a list of tabs that, when clicked, should switch to associated slides. Revisiting the tweakStructure
function, if the enablePagination
property in the config is true
, we will inject a nav element to the carousel that will act as a pagination container.
We will also add a CSS class and a tablist
role to the pagination container, which basically describes it as a list of tabs that help us navigate to different slides upon clicking these tabs:
const tweakStructure = () => { // ... if (enablePagination) { paginationContainer = addElement("nav", { class: "carousel-pagination", role: "tablist", }); carousel.appendChild(paginationContainer); } };
When iterating through the slides array in the tweakStructure
function, we should add a check for enablePagination
and inject a button element to the carousel element for each slide with the appropriate CSS class, a tab role, and some text to provide some labels.
The first button in the pagination should stay selected by default. This requires that we verify that the button’s index is zero, assign it the active CSS class, and set its aria-selected
role to true
.
Lastly, we should attach an event listener to every button and link it to the handlePaginationBtnClick
function with the slide index as its argument:
const tweakStructure = () => { // ... slides.forEach((slide, index) => { // ... if (enablePagination) { const paginationButton = addElement( "button", { class: `carousel-btn caroursel-btn--${index + 1}`, role: "tab", }, `Slide ${index + 1}` ); paginationContainer.appendChild(paginationButton); if (index === 0) { paginationButton.classList.add("carousel-btn--active"); paginationButton.setAttribute("aria-selected", true); } paginationBtn.addEventListener("click", () => { handlePaginationBtnClick(index); }); } }); };
The updatePaginationBtns
function picks the previously active button from the pagination container, removes the active CSS class from it, selects the currently active button using the currentSlideIndex
value, and then adds the active CSS class to it:
const updatePaginationBtns = () => { const btns = paginationContainer.children; const prevActiveBtns = Array.from(btns).filter((btn) => btn.classList.contains("carousel-btn--active") ); const currentActiveBtn = btns[currentSlideIndex]; prevActiveBtns.forEach((btn) => { btn.classList.remove("carousel-btn--active"); btn.removeAttribute("aria-selected"); }); if (currentActiveBtn) { currentActiveBtn.classList.add("carousel-btn--active"); currentActiveBtn.setAttribute("aria-selected", true); } };
Because updateCarouselState
handles the entire carousel’s state, we should add updatePaginationBtns
inside it under a conditional as shown below:
const updateCarouselState = () => { if (enablePagination) { updatePaginationBtns(); } adjustSlidePosition(); };
In the destroy
function, we may loop through an array of all the pagination buttons and detach the event listeners associated with each button:
const destroy = () => { // ... if (enablePagination) { const paginationBtns = paginationContainer.querySelectorAll(".carousel-btn"); if (paginationBtns.length) { paginationBtns.forEach((btn) => { btn.removeEventListener("click", handlePaginationBtnClick); }); } } };
See the Pen
Paginated JavaScript Carousel by Rahul (@_rahul)
on CodePen.
To implement the autoplay feature, let’s add two more properties to the config parameter of JSCarousel
to manage its activation interval:
const JSCarousel = ({ ... enableAutoplay = true, autoplayInterval = 2000, }) => { // ... };
Using the autoplayInterval
value from the carousel configuration, we can set up an interval and trigger moveSlide
to switch to the next slide at regular intervals.
To stop the slides from auto-playing, we set up stopAutoplay
and cleared the interval we established in the startAutoplay
function. Let’s also assign some handler functions for stopping and starting the auto-playing when the mouse enters and leaves:
const startAutoplay = () => { autoplayTimer = setInterval(() => { moveSlide("next"); }, autoplayInterval); }; const stopAutoplay = () => clearInterval(autoplayTimer); const handleMouseEnter = () => stopAutoplay(); const handleMouseLeave = () => startAutoplay();
In the attachEventListeners
function, we should add a check for the autoplay config options, attach mouse events to the carousel, and provide them with their respective event handlers. This will facilitate the user to halt the automatic slideshow when hovering over the carousel with the mouse:
const attachEventListeners = () => { // ... if (enableAutoplay && autoplayInterval !== null) { carousel.addEventListener("mouseenter", handleMouseEnter); carousel.addEventListener("mouseleave", handleMouseLeave); } };
Upon carousel initialization, we would also like to start the autoplay automatically if the enableAutoplay
is true. The following check will take care of that:
const create = () => { // ... if (enableAutoplay && autoplayInterval !== null) { startAutoplay(); } };
We would also like to stop the autoplay on cleanup, clear registered intervals, and detach all the attached event listeners. Here’s how we can accomplish that:
const destroy = () => { // ... if (enableAutoplay && autoplayInterval !== null) { carousel.removeEventListener("mouseenter", handleMouseEnter); carousel.removeEventListener("mouseleave", handleMouseLeave); stopAutoplay(); } };
See the Pen
Autoplaying JavaScript Carousel by Rahul (@_rahul)
on CodePen.
Firstly, we should make our carousel focusable with the sequential keyboard navigation (tab key) by adding tabindex
attribute with a 0 value, which indicates that the carousel should be placed in the default navigation order:
const tweakStructure = () => { carousel.setAttribute("tabindex", "0"); // ... };
With the event.key
object, we can control our carousel using the keyboard and trigger the moveSlide
function for either of the two directions upon pressing the left and right arrow keys respectively:
const handleKeyboardNav = (event) => { if (!carousel.contains(event.target)) return; if (event.defaultPrevented) return; switch (event.key) { case "ArrowLeft": moveSlide("prev"); break; case "ArrowRight": moveSlide("next"); break; default: return; } event.preventDefault(); };
One last thing is to add and remove keydown
events for the carousel element during the creation and removal of the carousel respectively:
const create = () => { // ... carousel.addEventListener("keydown", handleKeyboardNav); }; const destroy = () => { // ... carousel.removeEventListener("keydown", handleKeyboardNav); };
And that’s pretty much it! Check out the following demos to see all the features we have discussed so far in action:
See the Pen
JavaScript Carousels by Rahul (@_rahul)
on CodePen.
You can extend this carousel solution further and add more functionality to support showing multiple slides, cross-fading transitions, and even taking it further to your favorite JavaScript framework.
Add whatever desired content in the carousel, be it an image, actual markup content, testimonials, services, etc., and tweak CSS styles to suit your requirements. Either way, now you no longer have to use bulky libraries to create simple carousels.
I hope you enjoyed this tutorial! Find the complete source code for this tutorial here.
Debugging code is always a tedious task. But the more you understand your errors, the easier it is to fix them.
LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to see exactly what the user did that led to an error.
LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
7 Replies to "Build an image carousel from scratch with vanilla JavaScript"
Great guide, thanks for posting it!
Useful tutorial! Thanks for sharing. One issue I had is:
else {
curSlide-;
}”
Should be:
else {
curSlide–;
}”
Other than that, fantastic job!
Het im having The same problem what dis you chance The curslide – to?
How do I get the focus to return to a slide once I use the arrow to move back and forth? I’ve removed the photos and entered text in its place. Thank you.
nice thanks for sharing
should have two – on the prev button else statement to fix the issue.
else {
curside–;
}
The following JS script fixes the above issues.
document.addEventListener(“DOMContentLoaded”, function () {
// Select all slides
const slides = document.querySelectorAll(“.slide”);
// Initialize current slide counter
let curSlide = 0;
// Add event listeners for next and previous slide buttons
const nextSlideBtn = document.querySelector(“.btn-next”);
const prevSlideBtn = document.querySelector(“.btn-prev”);
nextSlideBtn.addEventListener(“click”, () => navigateSlides(1));
prevSlideBtn.addEventListener(“click”, () => navigateSlides(-1));
// Function to navigate slides
function navigateSlides(offset) {
curSlide = (curSlide + offset + slides.length) % slides.length;
slides.forEach((slide, index) => {
const translateValue = (index – curSlide) * 100;
slide.style.transform = `translateX(${translateValue}%)`;
});
}
});
I was able to get it to work with this code:
const slides = document.querySelectorAll(“.slide”);
slides.forEach((slide, indx) => {
slide.style.transform = `translateX(${indx * 100}%)`;
});
let curSlide = 0;
let maxSlide = slides.length – 1;
const nextSlide = document.querySelector(“.btn-next”);
nextSlide.addEventListener(“click”, function () {
if (curSlide === maxSlide) {
curSlide = 0;
} else {
curSlide ++;
}
slides.forEach((slide, indx) => {
slide.style.transform = `translateX(${100 * (indx – curSlide)}%)`;
});
});
const prevSlide = document.querySelector(“.btn-prev”);
prevSlide.addEventListener(“click”, function () {
if (curSlide === 0) {
curSlide = maxSlide;
} else {
curSlide –;
}
slides.forEach((slide, indx) => {
slide.style.transform = `translateX(${100 * (indx – curSlide)}%)`;
});
});