Category Archives: Web development

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!

Creating click-to-play YouTube videos in JS and CSS that don’t load anything until they’re needed

Let's be honest: streaming video is kinda hard. If you want to embed a video on your website, you're going to need it in multiple formats to support all the major browsers, and you'll probably want each of those in multiple resolutions too so your visitors with slower connections or less powerful devices aren't left out in the cold.

You can always roll your own native HTML5 player with a bit of messing about in ffmpeg and a DASH manifest, or go ready-made and embed JWPlayer or Video.js. Of course, since video can be pretty heavy, you might want to host the files from a CDN too.

But I just want a simple little website for my personal portfolio, and since I don't expect many visitors, it's just not worth the effort. I'm not the biggest Google fan but it's undeniable that YouTube have built a very competent platform, and it's very tempting to just throw a couple iframes up and call it a day. But my website is lightweight and fast (and I feel smug about it): it doesn't need to pull in any external resources, and I don't want Google tracking all of my visitors before they've even watched a video. With a few simple changes, we can make our embeds only load when they're clicked, and give them nice thumbnails and buttons to boot.

We start by creating our placeholder player:

<div class="youtube overlay" data-id="xi7U1afxMQY"> <a class="play" href="https://youtube.com/watch?v=xi7U1afxMQY" aria-label="Play video"> <div class="thumbnail-container"> <picture> <source srcset="thumbnails/mountains.avif 960w, thumbnails/mountains-2x.avif 1920w" type="image/avif"> <img class="thumbnail" srcset="thumbnails/mountains.jpg 960w, thumbnails/mountains-2x.jpg 1920w" src="thumbnails/mountains.jpg" alt="Life in the Mountains" loading="lazy"> </picture> <span class="duration">8:48</span> <div class="play-overlay"></div> </div> </a> </div>
Code language: HTML, XML (xml)

The ID of the video is stored in the data-id attribute, which we'll use later to insert the iframe. Since we'll need Javascript for this, the play link contains the full URL so non-JS users can click through to watch it directly on YouTube. We include a thumbnail, in JPG for compatibility and AVIF for better compression on modern browsers (avif.io is a great little online tool to convert all of your images, since as I write this it's rarely supported by image editors), and in two resolutions (960px and 1920px) as smaller screens don't need the full-size image. We also include the duration – why not? – and play-overlay will hold a play button icon.

We can now apply some CSS:

.overlay { position: relative; width: 100vw; height: calc((100vw/16)*9); max-width: 1920px; max-height: 1080px; } .overlay .thumbnail-container { position: relative; } .overlay .thumbnail { display: block; } .overlay .duration { position: absolute; z-index: 2; right: 0.5rem; bottom: 0.5rem; padding: 0.2rem 0.4rem; background-color: rgba(0, 0, 0, 0.6); color: white; } .overlay .play-overlay { position: absolute; z-index: 1; top: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.1) url("images/arrow.svg") no-repeat scroll center center / 3rem 3rem; transition: background-color 0.7s; } .overlay .play-overlay:hover { background-color: rgba(0, 0, 0, 0); } .overlay iframe { position: absolute; z-index: 3; width: 100%; height: 100%; }
Code language: CSS (css)

On my site I've already set the width and height for the video's container, so I've just shown an example for overlay here, using vw units so it fills the viewport's width whether portrait or landscape. My thumbnails only go up to 1920x1080 so I've limited it to that in this example. Sorry 4K users! You can use a calc expression for the height to get the correct aspect ratio (here 16:9).

On to positioning. Setting position: relative for the container means we can use absolute positioning for the iframe to fit to the thumbnail's size, and position: relative on the thumbnail's container and display: block on the thumbnail itself fits everything else to the thumbnail too. Duration sits in the bottom right with a little space to breathe. We set z-indexes so elements will stack in the correct order: thumbnail on the bottom, overlay above it, duration on top of that, and the iframe will cover everything once it's added.

What remains is just little extras: the overlay slightly darkens the thumbnail until it's hovered over, and we take advantage of the background property allowing both colour and URL to drop a play button on top. The button is an SVG so simple you can paste the code into arrow.svg yourself:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="0 0 100 50 0 100 0 0" style="fill:#fff"/></svg>
Code language: HTML, XML (xml)

Now all we need is a little JS to handle inserting the iframe when the placeholder is clicked – no JQuery required! Insert it just before the closing </body> tag so it runs once all the placeholders it'll be working on have loaded.

document.querySelectorAll(".youtube").forEach(function(element) { element.querySelector(".play").addEventListener("click", (event) => { event.preventDefault(); loadVideo(element); }); }); var loadVideo = function(element) { var iframe = document.createElement("iframe"); iframe.setAttribute("src", "https://www.youtube-nocookie.com/embed/" + element.getAttribute("data-id") + "?autoplay=1"); iframe.setAttribute("frameborder", "0"); iframe.setAttribute("allowfullscreen", "1"); iframe.setAttribute("allow", "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"); element.insertBefore(iframe, element.querySelector(".play")); };
Code language: JavaScript (javascript)

The first function finds every placeholder on the page, adding a listener for each play button being clicked. Note that we use the overlay class for the CSS but youtube for the JS – this is so we can extend our code later to cover more platforms if we like, which would need different JS. When a visitor clicks play, it cancels the default action (navigating to the URL, which we included for non-JS users) and calls the loadVideo function, passing on the specific video they clicked.

The loadVideo function puts together the iframe for the video embed, getting the ID from the container's data-id attribute. We use www.youtube-nocookie.com (the www is necessary!) as it pinkie promises not to set cookies until you play the video (why not, right?), and set a few attributes to let mobile users rotate the screen, copy the link to their clipboard etc. Although we set it to autoplay since we've already clicked on the placeholder, it doesn't seem to work as I write this. I'm not sure why and they encourage you to embed their JS API instead, but that would sort of defeat the point. Finally, it inserts the iframe as the first element in the container, where it covers up the rest.

If all goes well, you should now have something that looks like this (albeit functional):

Completed placeholder for click-to-play video

You can also see it in action on my website. Thanks for reading!