Building a loopable slider/carousel for my portfolio in vanilla JS and CSS

Stuck in lockdown in this most cursed year, I finally decided to throw together the portfolio website I've been putting off forever. I've been meaning to play with static site generators, but I've become fat and lazy on WordPress plugins and figured my core could use a workout. I wanted to be able to hand-write a snappy, responsive site in nothing more than HTML, CSS and a little JS – no frameworks and no external resources – that would still make sense when I wanted to add to it later.

I chose flexbox over the newer CSS Grid purely for the practice, so it took a little more work to have both rows and columns in my layout (it's broadly designed for one or the other). I wanted to split my work up into categories, then for each of those arrange a selection of items in rows. Instead of stacking rows, which would make my single page way too long, I decided to treat them as slides in a carousel and use navigation buttons to move left and right. With flexbox this is easy, since we can specify the order in which rows appear and use CSS transitions to animate nicely between them. A little JS handles the navigation, and we can support non-JS users by simply letting the rows stack as they normally would.

I won't go into too much detail on how I set up the overall layout – it's fairly simple and you're welcome to use my source for inspiration. I've tried to annotate it well enough that you can recreate it yourself, but feel free to leave a comment or email if you get stuck anywhere.

Let's create our first section and insert a container for our carousel rows:

<div class="section-container">
	<header>
		<h2>Section title</h2>
	</header>
	<div class="section-content carousel-outer">
		<nav class="carousel-buttons">
			<button class="carousel-left" aria-label="left">&lt;</button>
			<button class="carousel-right" aria-label="right">&gt;</button>
		</nav>
		<div class="section-intro">
			<p>Section introduction</p>
		</div>
		<div class="carousel">
			<div class="row">
				<div class="column">
					<picture>
						<source srcset="images/item-1.avif" type="image/avif">
						<img src="images/item-1.png" alt="Item 1 alt" loading="lazy">
					</picture>
				</div>
				<div class="column">
					<div class="item-description">
						<h3>Item 1 title</h3>
						<p>Item 1 description</p>
					</div>
				</div>
			</div>
			<div class="row">
				<div class="column">
					<picture>
						<source srcset="images/item-2.avif" type="image/avif">
						<img src="images/item-2.png" alt="Item 2 alt" loading="lazy">
					</picture>
				</div>
				<div class="column">
					<div class="item-description">
						<h3>Item 2 title</h3>
						<p>Item 2 description</p>
					</div>
				</div>
			</div>
		</div>
	</div>
</div>
Code language: HTML, XML (xml)

And style it (I haven't shown how I've styled the contents of each row, just to simplify things):

.section-container {
	overflow: hidden;
}

.row {
	flex: 0 0 100%;
}

.carousel {
	display: flex;
	flex-flow: row nowrap;
	transform: translateX(-100%);
}

.carousel-transition {
	transition: transform 0.7s ease-in-out;
}

.carousel-buttons {
	float: right;
	margin-top: -4rem;
	padding: 1rem;
}

.carousel-buttons button {
	height: 4rem;
	width: 4rem;
	font-size: 3rem;
	font-weight: 900;
}

.carousel-buttons button:nth-of-type(1) {
	margin-right: 1rem;
}
Code language: CSS (css)

We set overflow: hidden on section-container to hide the inactive slides to the left and right. The flex property on row sets it to 100% of the width of its container, without being allowed to grow or shrink. row nowrap on carousel will display the slides side-by-side, and by default we translate the carousel 100% (i.e. one slide) to the left, which I'll explain later. We add a few more styles to animate the carousel's movement (with a separate class, important for later), and place the navigation buttons above the container on the right hand side. Note that we don't style carousel-outer at all – this is purely used by our navigation JS later.

For non-javascript users, we want the slides to stack instead, so we set carousel to row wrap. We remove the translation, hide the navigation buttons and add padding to the bottom of every slide but the last. Handily, putting a <style> inside a <noscript> is now valid as of HTML5, so we can drop this after our linked styles in the <head> to only apply these changes to non-JS users:

<noscript>
	<style>
		/* show all slides if js is disabled */
		.section-content .carousel {
			flex-flow: row wrap;
			transform: translateX(0);
		}
		
		.carousel .row {
			padding-bottom: 4rem;
		}
		
		.carousel .row:nth-last-of-type(1) {
			padding-bottom: 0;
		}
		
		.carousel-buttons {
			display: none;
		}
	</style>
</noscript>
Code language: HTML, XML (xml)

All we need now is a little JS to move the slides when the buttons are clicked. We place this inline at the bottom of our HTML before the closing </body> tag, so it won't run until all the elements we need have loaded. I'll run through it section by section.

document.querySelectorAll(".carousel-outer").forEach(function(element) {
	let total_items = element.querySelectorAll(".row").length;
	element.querySelectorAll(".row").forEach(function(slide, index) {
		if (index + 1 == total_items) {
			slide.style.order = 1;
		} else {
			slide.style.order = index + 2;
		}
	});
	element.querySelector(".carousel-left").addEventListener("click", () => {
		prevSlide(element);
	});
	element.querySelector(".carousel-right").addEventListener("click", () => {
		nextSlide(element);
	});
	element.querySelector(".carousel").addEventListener("transitionend", (event) => {
		updateOrder(event, element);
	});
});
Code language: JavaScript (javascript)

Our first function runs when the page first loads, once for each carousel-outer (i.e. each carousel) on the page. It counts the number of slides (rows) then sets the CSS order property for each to determine the order they will appear on the page. We use JS for this so we don't have to manually update the CSS for every slide if we add or remove any later. Since index (the order slides appear in the HTML) starts at 0 and CSS order at 1, we work with index + 1.

If we've found the final slide, we make that the first in the order. If not, we add 1 (remember we already need to add 1 to index, so it's really 2). The reason we do this is so the user can navigate left to view the final slide, and having it already there in the first position means we can animate it in. So the first slide in the HTML will be in position 2, the second in position 3, etc etc, and the last in position 1. This is why we applied transform: translateX(-100%) to the carousel earlier: this moved every slide one position to the left, so our first slide (position 2) will be immediately visible, our second slide (position 3) off-screen to the right, and our last slide (position 1) off-screen to the left. Everything is now ready to be animated!

Before we do that, we add a few EventListeners to handle the buttons. The first listens for each left navigation button being clicked, calling prevSlide and passing on which carousel needs moving. The second does the same for the right button, calling nextSlide. The last listens for animations finishing on each carousel, calling updateOrder when we need to update the CSS order to reflect what's currently on display. Let's cover nextSlide and prevSlide first.

var prevSlide = function(element) {
	element.querySelector(".carousel").classList.add("carousel-transition");
	element.querySelector(".carousel").style.transform = "translateX(0)";
};
var nextSlide = function(element) {
	element.querySelector(".carousel").classList.add("carousel-transition");
	element.querySelector(".carousel").style.transform = "translateX(-200%)";
};
Code language: JavaScript (javascript)

These are both pretty simple. We're passed the carousel-outer containing the clicked button as element, so we look within that for an element with the carousel class, and add the carousel-transition class to it to enable the animation. More on that later. To move to the previous slide, we then translate the carousel on the x-axis to 0. Remember we're starting at -100%, so this moves everything to the right by one slide. To move to the next slide, we translate to -200%, a difference of -100%, so everything moves to the left by one slide.

Now for updateOrder:

var updateOrder = function(event, element) {
	if (event.propertyName == "transform") {
		let total_items = element.querySelectorAll(".row").length;
		if (element.querySelector(".carousel").style.transform == "translateX(-200%)") {
			element.querySelectorAll(".row").forEach(function(slide) {
				if (slide.style.order == 1) {
					slide.style.order = total_items;
				} else {
					slide.style.order--;
				}
			});
		} else {
			element.querySelectorAll(".row").forEach(function(slide) {
				if (slide.style.order == total_items) {
					slide.style.order = 1;
				} else {
					slide.style.order++;
				}
			});
		}
	}
	element.querySelector(".carousel").classList.remove("carousel-transition");
	element.querySelector(".carousel").style.transform = "translateX(-100%)";
};
Code language: JavaScript (javascript)

We want our carousel to be loopable: when you get to the final slide, you should be able to keep moving to get back to the first. So we can't just keep translating by -100% or 100% every time! Instead, once the animation is finished (hence why we run this on transitionend), we reset the CSS order so the slide on display is now in position 2, and, without animating again, instantly translate the carousel back to its original -100% to counteract this change. I'll admit this confused me a bit at the time, so let me take you through it step by step.

We passed through event to our function so we can check what animation type triggered it. The listener also picks up animations of child elements within carousel, and since I animate opacity changes for my click-to-play YouTube videos, we first need to exclude anything that isn't a transform.

As before, we count the number of row elements within the carousel, then look at the current state of the transform property to work out which direction we've just moved in. If it's -200%, we've moved left, otherwise we must have moved right. If we moved left, we reduce each slide's order by 1 to reflect its actual position. So the slide previously on display, which was in position 2, should now be in position 1; the new slide on display, which was in position 3, should now be in position 2; and so on. We want the final slide, (which was just off to the left) to loop around to the other end, so that gets the highest position. We do the opposite if we moved right: we increase each slide's order by 1, and if it was already the highest, we put that in position 1 so it's ready on the left for our next move.

Of course, what we've just done here is a repeat what we already did with the transform property. We already translated the carousel one position to the left or right, now we've done the same again with the CSS order – just without the nice animation. We don't want to move by two slides at a time, so now we reset the transform property back to its original -100%, ready for the next move. But first we disable animation by removing the carousel-transition class, making the switch invisible to the visitor. This also has the convenient side-effect of stopping transitionend from firing on our reset, which would otherwise call updateOrder again and make our carousel loop infinitely!

That's just about it! I can think of a couple of simple ways to extend this, like making the carousels draggable for easier mobile use, letting the keyboard arrows move whichever carousel is in view, and using an Intersection Observer to lazyload any images in the previous slide in line (right now only the next slide's images load before they enter the viewport). But that's all out of scope for my little website – maybe I'll get around to it in a couple of years 😉

You can see the finished carousel in action on my portfolio, and thanks to Useful Angle for giving me the inspiration to use CSS order to make it loop!

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.