JavaScript: how to create a product carousel

JavaScript: how to create a product carousel

In this article we will see how to create a product carousel with JavaScript.

In this article we will see how to create a product carousel with JavaScript.

The carousel we're going to create will extend across the width of the browser window and will be centered vertically.

Each item in the carousel will have an image, title, description, and price. When you click on the item, a modal window will open showing the image, the price and a call to action. The modal window can be closed either with a click on a close button or with a click anywhere in the window that is not the main content.

The carousel will scroll from right to left. The number of items selected for scrolling will be dynamically determined based on the resolution of the browser window. Since the content will be fetched dynamically from a remote API, we will initially place some dummy static content as placeholders and show a loading indicator that will disappear when the AJAX request completes and the remote images have been loaded.

The HTML structure

Let's start with the basic HTML structure.

<section id="carousel" class="carousel">
        <nav class="carousel-nav">
            <button type="button" class="carousel-prev">
                <span class="sr">Previous</span>
                <img src="icons/angle-left-solid.svg" alt="" aria-hidden="true">
            </button>
            <button type="button" class="carousel-next">
                <span class="sr">Next</span>
                <img src="icons/angle-right-solid.svg" alt="" aria-hidden="true">
            </button>
        </nav>
        <ol class="carousel-wrap">
            <li class="carousel-item">
                <img class="carousel-image"  src="images/1.jpg" alt="">
            </li>
            <!--...-->
        </ol>    
</section>
<div id="carousel-overlay">
        <a href="#carousel-overlay" id="carousel-overlay-close">&times;</a>
        <div id="carousel-overlay-content">
            <article id="carousel-overlay-product"></article>
        </div>
</div>
<div id="loader" aria-hidden="true">Loading...</div>        

CSS styles

The first step is to define the global CSS styles of the document.

@font-face {
    font-family: 'Inter';
    src: url('fonts/Inter-Regular.ttf') format('truetype');

}    

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html, body {
    overflow-x: hidden;
}

body {
    width: 100%;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
    font-family: Inter, sans-serif;
}

button {
    appearance: none;
    border: none;
    background-color: transparent;
}

.sr {
    position: absolute;
    top: -9999em;
}

overflow-x: hidden is essential as we don't want the browser to display a horizontal scrollbar, which is unavoidable as elements will exceed the overall width of the window.

The body element here is a Flex box with a columnar layout so as to vertically center the carousel on the page. This is also made possible by the fact that body covers the entire height of the window thanks to the declaration min-height: 100vh (the unit vh, remember, stands for viewport height).

Now we can define the styles of the bar that contains the "Previous" and "Next" buttons.

.carousel {
    position: relative;
}

.carousel-nav {
    position: absolute;
    top: 50%;
    left: 0;
    width: 100%;
    transform: translateY(-50%);
    display: flex;
    align-items: center;
    justify-content: space-between;
    z-index: 2;
}

.carousel-nav button {
    width: 24px;
    height: 24px;
    display: block;
    cursor: pointer;
    background-color: #fff;
    border-radius: 50%;
}

.carousel-nav button img {
    display: block;
    width: 100%;
    height: 100%;
}

Absolute positioning occurs contextually. By declaring position: relative on the parent container, the navbar will position itself relative to that container and not relative to the entire browser window. Here the bar is vertically centered using the top and transform properties together: 50% of one is offset by the same negative value of the other with the translateY function (). The two buttons are placed respectively on the left and on the right with the declaration justify-content: space-between which in a Flex context evenly distributes the space between the elements.

So let's move on to defining the main styles of the carousel itself.

.carousel, .carousel-wrap {
    width: 100%;
    display: flex;
}

.carousel-wrap {
    position: relative;
    z-index: 1;
    transition: all 400ms ease-in-out;
    list-style: none;
}

.carousel-item {
    min-width: 16.6666%;
    max-width: 100%;
    margin: 0;
    position: relative;
    cursor: pointer;
    display: block;
    height: 300px;
    overflow: hidden;
}

@media screen and (max-width: 768px) {
    .carousel-item {
        height: 200px;
    }    
}

The sorted list .carousel-wrap is declared as a Flex container and a CSS transition is defined on it which will later be used to animate horizontal scrolling. List items .carousel-item have an adaptive width specified as a percentage: the minimum width is 16%, obtained by dividing 100 (total width) by 6 in order to have 6 visible items for each scroll , while the maximum width, used on small screens, is 100%. The height will be 300 pixels in the largest resolutions and 200 pixels on mobile devices in portrait mode.

Each list item in turn creates a contextual positioning with the position: relative declaration. Since the elements of each item will be positioned absolutely, we use the overflow: hidden declaration to prevent the contents from overflowing in height or width from each item.

We then define the styles for the elements contained in the list items.

.carousel-image {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.carousel-caption {
    margin: 0;
    opacity: 0;
    display: block;
    width: 100%;
    position: absolute;
    bottom: 0;
    left: 0;
    background-color: rgba(0, 0, 0, 0.7);
    color: #fff;
    padding: 1rem;
    line-height: 1.5;
    transition: opacity 400ms ease-in;
}

.carousel-caption span {
    display: block;
}

.carousel-caption .carousel-title {
    font-size: 1.2rem;
    letter-spacing: .1rem;
    text-transform: uppercase;
    margin-bottom: 0.35rem;
    line-height: 1;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.carousel-item:hover .carousel-caption {
    opacity: 1;
}

The product image will fit the width of the element via the object-fit: cover declaration. Note that the object-fit property only takes effect when the image has a stated size. The description box that contains the title, price and caption of the product is positioned absolutely in the lower part of the element and is initially hidden by resetting its opacity. When the user hovers over the element, the opacity is reset to its default value.

To adjust this feature and the size of carousel items on mobile devices, we need to add two Media Queries.

@media screen and (max-width: 1024px) {
    .carousel-item {
        min-width: 50%;
    }  
    .carousel-caption {
        opacity: 1;
    }  
}

@media screen and (max-width: 768px) {
    .carousel-item {
        min-width: 100%;
    }
        .carousel-caption {
            opacity: 1;
        }
}

On smartphones (maximum width 768 pixels), we will only have one item visible at a time because the minimum width will be 100%. On tablets (maximum width 1024 pixels), we will have two entries visible at a time because the minimum width will be 50%. In both cases the description box will always be visible because on mobile the hover over the elements is not present.

The terms used, smartphone and mobile, are not entirely accurate: in reality the Media Queries in this case select the width of the screen and not the device in use, so if you resize the browser window you will get the same results on desktop as well .

We now need to define the styles for the modal popup that will be shown when clicking on each item in the carousel.

#carousel-overlay {
    position: fixed;
    width: 100%;
    height: 100vh;
    top: 0;
    left: 0;
    z-index: 9999999;
    background-color: rgba(255, 255, 255, 0.7);
    color: #000;
    display: none;
    transition: opacity 400ms ease-in;
    opacity: 0;
}

#carousel-overlay-close {
    position: absolute;
    top: 1rem;
    right: 1rem;
    text-decoration: none;
    z-index: 1000;
    color: #000;
    font-size: 1.4rem;
}

#carousel-overlay-content {
    position: absolute;
    z-index: 2000;
    padding: 1rem;
    background-color: #fff;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    box-shadow:
            0 2.8px 2.2px rgba(0, 0, 0, 0.034),
            0 6.7px 5.3px rgba(0, 0, 0, 0.048),
            0 12.5px 10px rgba(0, 0, 0, 0.06),
            0 22.3px 17.9px rgba(0, 0, 0, 0.072),
            0 41.8px 33.4px rgba(0, 0, 0, 0.086),
            0 100px 80px rgba(0, 0, 0, 0.12);
    border-radius: 5px;
    width: 40%;
}

@media screen and (max-width: 768px) {
    #carousel-overlay-content {
        left: 0;
        transform: none;
        width: 100%;
        height: 100vh;
        top: 0;
        border-radius: 0;
        box-shadow: none;
    }
    #carousel-overlay-close {
        z-index: 3000;
    }
}

#carousel-overlay-product {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
}

#carousel-overlay-product .carousel-product-details {
    width: 60%;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
}

#carousel-overlay-product .carousel-product-details button {
    appearance: none;
    border: none;
    cursor: pointer;
    padding: 0.85rem 1.2rem;
    background-color: #000;
    color: #fff;
    text-transform: uppercase;
    letter-spacing: .1em;
}

#carousel-overlay-product .carousel-product-details span {
    font-size: 1.2rem;
    font-weight: bold;
    color: #000;
    display: inline-block;
    margin-right: 0.75rem;
}

#carousel-overlay-product figure {
    width: 35%;
    margin-right: 5%;
}

#carousel-overlay-product figure img {
    display: block;
    max-width: 100%;
    height: auto;
}

@media screen and (max-width: 768px) {
    #carousel-overlay-product {
        height: 100%;
        flex-direction: column;
        justify-content: center;
    } 
    #carousel-overlay-product .carousel-product-details {
        width: 100%;
    }
    #carousel-overlay-product figure {
        width: 100%;
        margin-bottom: 1rem;
        margin-right: 0;
    }
}

The modal window consists of a semi-transparent background element and its main content centered horizontally and vertically within the background. On small screens this content will always remain centered thanks to the Flexbox properties, but the image and the box with the call to action will be displayed vertically instead of horizontally as in higher screen resolutions. This is possible thanks to the Flex model, provided that each container has a declared heighta which is independent of the automatic calculation of the intrinsic heights of each descending element.

The last element to define is the initial loading indicator.

#loader {
    position: fixed;
    top: 0;
    left: 0;
    background-color: #fff;
    color: #000;
    z-index: 999999;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    font-size: 1.5rem;
    text-transform: lowercase;
    font-weight: bold;
    letter-spacing: .1rem;
    transition: opacity 400ms ease-out;
}

We often use fixed positioning when defining full-screen elements for two reasons: this positioning allows us to have a visual precedence over the other elements of the page and, last but not least, it cancels the vertical scrolling so as to avoid the appearance of the vertical bar of the browser if there are other elements on the page in addition to our carousel.

The JavaScript code

First we need to define utility functions that will contain the logic of common actions within our code.

Let's start with getting the products from remote APIs.

const getData = async (limit = 24) => {
        try {
            const res = await fetch(`https://dummyjson.com/products/?limit=${limit}`);
            return await res.json();
        } catch(err) {
            return [];
        }
    };

We use the async/await model with the Fetch API to avoid chained callbacks and instead return the results directly. However, remember that the returned Promise will return the product array in case of success or an empty array in case of error.

Since some of the behavior of our carousel will be different on mobile devices, we need to define a function that allows us to detect these devices.

const isMobile = () => /mobile/gi.test(navigator.userAgent);

The function searches for a match between the string returned by the userAgent property and the mobile or Mobile substring, present in mobile browsers.

Now we need to find the overall width of the browser window. We can define a function for this purpose.

const getViewportWidth = () => {
        return (window.innerWidth || document.documentElement.clientWidth);    
    };

The logical OR present in the function will select the innerWidth property of the window object if this is available or alternatively the clientWidth property of the root element of the HTML document (in this case html). The integer returned will represent the width of the browser window in pixels.

We then define a function that preloads the remote images retrieved from the API.

const preloadImage = src => {
        return new Promise((resolve, reject) => {
            const image = new Image();
            image.onload = () => resolve({src, status: true});
            image.onerror = () => reject({src, status: false});
            image.src = src;
        });
    };

The function returns a Promise where the resolved state is connected to the load event of the current image and the rejected state to the error event. In this way, when we create a new instance of the Image object and assign it the src property via the function parameter, the resolution of the two events will be triggered which will indicate that loading (load) or a possible error (error).

Now we must instead define a function that allows us to establish whether an element of the carousel is visible in the area formed by the width of the browser window (viewport).

const isInViewport = element => {
        if(element === null || !element) {
            return false;
        }
        const rect = element.getBoundingClientRect();
        return (rect.left >= 0 && rect.right <= (window.innerWidth || document.documentElement.clientWidth));
    };

If an element has the left offset (left) equal to or greater than 0 and the right offset (right) less than or equal to the width of the browser window, the function will return true, otherwise false. Offsets are properties of the object obtained by invoking the getBoundingClientRect() method of the examined element. Note that these values will equal 0 if the element has been hidden with the display: none CSS declaration, because in that case the element's layout model has been completely reset.

The last utility function to define is the one that manages the horizontal scrolling of the carousel.

const slideTo = (element = null, offset = 0, delay = 400) => {
        return new Promise(( resolve, reject ) => {
            if(element === null) {
                reject(false);
            }
            const sign = offset === 0 ? '' : '-';
            const rule = `translateX(${sign}${offset}px)`;
            element.style.transform = rule;
            setTimeout(() => {
                resolve(true);
            }, delay);
        });
        
    };

The offset parameter defines the value in pixels with which to move the carousel from right to left. The CSS function translateX() will receive a negative value (sign equal to -) if the pixel value is greater than 0, otherwise a value positive (sign equal to an empty string). The function returns a Promise because we need to create a delay with the setTimeout() function equal to the value in milliseconds of the delay parameter so that scrolling is synchronized with the transition css.

We have therefore arrived at the moment of managing the functioning of the carousel itself. Let's create a Carousel class with these initial components.

class Carousel {
        constructor(settings = {}) {
            const defaults = {
                container: '.carousel',
                previousBtn: '.carousel-prev',
                nextBtn: '.carousel-next',
                wrapper: '.carousel-wrap',
                item: '.carousel-item',
                overlay: '#carousel-overlay',
                overlayClose: '#carousel-overlay-close',
                overlayProduct: '#carousel-overlay-product'
            };
            this.options = Object.assign(defaults, settings);
            this.container = document.querySelector(this.options.container);
            this.previousBtn = this.container.querySelector(this.options.previousBtn);
            this.nextBtn = this.container.querySelector(this.options.nextBtn);
            this.wrapper = this.container.querySelector(this.options.wrapper);
            this.items = this.container.querySelectorAll(this.options.item);
            this.loader = document.getElementById('loader');
            this.overlay = document.querySelector(this.options.overlay);
            this.overlayClose = document.querySelector(this.options.overlayClose);
            this.overlayProduct = document.querySelector(this.options.overlayProduct);
            this.pages = 0;
            this.index = 0;
            this.images = [];

            if(this.container !== null) {
                this.init();
            }
        }

        init() {
            this.setVisibleItems();
            this.setPages();
            this.handleHideOverlay();
            this.onResize();
            this.next();
            this.previous();
            this.getDataItems();
        }
}       

The settings object passed to the constructor will contain in its properties the CSS selectors that will allow us to create references to the DOM elements. The one exception is the loading indicator, which in a real situation would be a component outside our carousel.

settings will then be combined with the Object.assign() method which will allow you to have the final options object. This object actually allows you to change the selectors in the HTML and CSS code while keeping the carousel functioning intact.

pages will contain the number of pages the carousel will be divided into based on the total number of items. In fact, if at the beginning we have 24 elements, and there are 6 elements visible at each scroll on a large screen, we will have 24 / 6 pages, ie 4. This value will be different on other screen resolutions.

index is the index that will be increased or decreased by 1 with each click on the navigation buttons and keeps track of the current page of the carousel we are on after each scrolling.

images is an array that stores the URLs of images loaded by the remote API after the initial AJAX call.

The first method called within the init() initialization method is setVisibleItems().

resetVisibleItems() {
            const visibles = this.container.querySelectorAll('.carousel-item-visible');
            if(visibles.length === 0) {
                return false;
            }
            for(const visible of visibles) {
                visible.classList.remove('carousel-item-visible');
            }
        }

        setVisibleItems() {
            if(isMobile() && getViewportWidth() <= 768) {
                return false;
            }
            this.resetVisibleItems();
            for(const item of this.items) {
                if(isInViewport(item)) {
                    item.classList.add('carousel-item-visible');
                }
            }
        }

This method and its helper resetVisibleItems() add or remove the CSS class carousel-item-visible which has the sole purpose of marking those carousel items in the DOM that are visible in the browser window. However, if we are on a mobile device with a resolution lower than or equal to 768 pixels, we will have a single visible element and therefore the main logic of the method cannot be applied.

The added CSS class is mainly to get the number of pages the carousel is divided into.

setPages() {
            this.pages = this.getPages();
        }

        getPages() {
            const visibles = this.container.querySelectorAll('.carousel-item-visible');
            if(visibles.length === 0) {
                return this.items.length;
            }    
            return Math.floor(this.items.length / visibles.length);
        }

The number of pages, as already mentioned, is obtained by dividing the total number of elements by the number of visible elements, i.e. those elements which each time you scroll will have the CSS class .carousel-item-visible applied to them.

Now we need to handle the appearance and disappearance of the modal window.

showOverlay() {
            this.overlay.style.display = 'block';
            this.overlay.style.opacity = 1;
        }

        hideOverlay() {
            this.overlay.style.opacity = 0;
            setTimeout(() => {
                this.overlay.style.display = 'none';
            }, 400);
        }

        handleHideOverlay() {
            this.overlayClose.addEventListener('click', e => {
                e.preventDefault();
                this.hideOverlay();
            }, false);
            this.overlay.addEventListener('click', evt => {
                const targetElement = evt.target;
                if(targetElement.matches(this.options.overlay)) {
                    const click = new Event('click');
                    this.overlayClose.dispatchEvent(click);
                }
            }, false);
        }

The action is mainly recorded on the close button located at the top right of the window. It's just a matter of handling the display and opacity CSS properties and the CSS transition delay. To allow the user to close the window without necessarily using the close button, we use event delegation on the window itself: if the user clicked anywhere outside the main content, we trigger the click event registered on the close button. The key point to understand is that the main event on the close button must be registered before the event delegation routine.

At the responsive level, we have to recalculate the number of visible elements and pagination whenever the size of the browser window changes.

onResize() {
            window.addEventListener('resize', () => {
                this.setVisibleItems();
                this.setPages();
            }, false);
        }

Now we can define the behavior of the carousel when the "Next" button is clicked.

getOffset(index, width) {
            return Math.round(index * width);
        }
        
next() {
            const self = this;
            self.nextBtn.addEventListener('click', () => {
                const pageWidth = self.getVisibleItemsWidth();
                self.index++;
                if(self.index > self.pages) {
                    self.index = 1;
                }
                slideTo(self.wrapper, self.getOffset(self.index,pageWidth)).then(() => {
                    self.setVisibleItems();
                });
            }, false);
        }
        
        getVisibleItemsWidth() {
            if(isMobile() && getViewportWidth() <= 768) {
                const item = this.items[0];
                const rectItem = item.getBoundingClientRect();
                return Math.round(rectItem.width);
            }
            const visibles = this.container.querySelectorAll('.carousel-item-visible');
            if(visibles.length === 0) {
                return 0;
            } 
            let width = 0; 
            for(const visible of visibles) {
                const rect = visible.getBoundingClientRect();
                width += rect.width;
            }
            return Math.round(width);  
        }

The offset for horizontal scrolling is calculated by multiplying the current page index with the total width of the visible elements. This width is obtained by summing the calculated widths of all elements with CSS class .carousel-item-visible. On mobile and with a resolution lower than or equal to 768 pixels, the calculation is made considering only the first element with class .carousel-item-visible. Here the page index is incremented by 1 and reset to 1 if it exceeds the number of available pages.

The logic of the "Previous" button is identical, but here the index is decremented by 1 and reset to 0 if it is less than or equal to 0.

previous() {
           const self = this;
           self.previousBtn.addEventListener('click', () => {
                const pageWidth = self.getVisibleItemsWidth();
                self.index--;
                if(self.index <= 0) {
                    self.index = 0;
                }
                slideTo(self.wrapper, self.getOffset(self.index,pageWidth)).then(() => {
                    self.setVisibleItems();
                });
            }, false); 
        }

Finally, getDataItems() performs all the asynchronous operations on our carousel, mainly those relating to the API call and the preload of remote images.

setDataItems(data) {
            if(!Array.isArray(data.products) || data.products.length === 0) {
                return false;
            }
            const { products } = data;
            this.items.forEach((item, index) => {
                let caption = document.createElement('div');
                caption.className = 'carousel-caption';
                let product = products[index];
                let { title, description, thumbnail, images, price } = product;
                let displayPrice = price.toFixed(2).replace('.', ',');
                this.images.push({thumbnail, image: images.length > 0 ? images[0] : thumbnail });
                caption.innerHTML = `<span class="carousel-title"><span>${title}</span> <span>&euro;${displayPrice}</span></span><span class="carousel-desc">${description}</span>`;
                item.appendChild(caption);
            });
        }
        preloadImages() {
            if(this.images.length === 0) {
                return false;
            }
            const tasks = [];
            for(const img of this.images) {
                let { thumbnail, image } = img;
                tasks.push(preloadImage(thumbnail));
                tasks.push(preloadImage(image));
            }
            return Promise.all(tasks);
        }
        
        handleItemsClick() {
            for(const item of this.items) {
                item.addEventListener('click', () => {
                    const title = item.querySelector('.carousel-title');
                    const img = item.querySelector('img');
                    const childs = title.querySelectorAll('span');
                    const price = childs[1].innerHTML;
                    let content = `<figure><img src="${img.dataset.main}"></figure>`;
                    content += `<div class="carousel-product-details"><span>${price}</span><button>Buy Product</button></div>`;
                    this.overlayProduct.innerHTML = content;
                    this.showOverlay();
                }, false);
            }
        }
        
        setItemImages() {
            if(this.images.length === 0) {
                return false;
            }
            this.items.forEach((item, index) => {
                let src = typeof this.images[index] === 'object' ? this.images[index] : null;
                if(src !== null) {
                    let img = item.querySelector('img');
                    img.setAttribute('src', src.thumbnail);
                    img.setAttribute('data-main', src.image);
                }
            });
        }
        
        hideLoader() {
            this.loader.style.opacity = 0;
            setTimeout(() => {
                this.loader.style.display = 'none';
            }, 400);
        }
        
getDataItems() {
            const self = this;
            getData().then(items => {
                self.setDataItems(items);
                self.preloadImages().then(srcs => {
                    self.handleItemsClick();
                    self.setItemImages();
                    self.hideLoader();
                }).catch(e => {
                    self.hideLoader();
                });
            }).catch(err => console.log(err));
        }

This method, and its helper methods, become effectively useless if the carousel doesn't need to have its contents loaded asynchronously via remote APIs. Obviously the preload of the images, the management of the modal window and of the loading indicator will have to be extracted from within the method and inserted in their proper positions and order.

Demo

JavaScript Product Carousel