As I continue to play around with and learn more about web components, I thought I'd build a simple component to make it easier to add a slideshow. By that, I mean something that renders one picture but provides controls to go to more images. I've probably built this many times in the past, both in JavaScript and server-side code, and I thought it would be a nice candidate for a component. As with most of my demos so far, there's a lot more that could be done with it, but I thought I'd share what I have so far. Once again I want to give a shout-out to Simon MacDonald for helping me get this code working. (At the end of the post, I'll share the mistake I made, as I think it's something others will run into, as well as a modified version Simon built.)

Ok, so I began by "designing" how I wanted to use the component in a regular HTML page. I wanted to allow for a list of images passed in via an attribute:

<slide-show images="
	https://placekitten.com/500/500,
	https://picsum.photos/id/1/500/500,
	https://via.placeholder.com/500,
	https://placebear.com/500/500,
	https://baconmockup.com/500/500
	">
</slide-show>

Note that I added some space around the URLs. I did that to make the code more readable and easier to modify. (I had to modify my source URLs a few times.) The tag also supports a width attribute and generally should always be used but it defaults to 500.

<slide-show width="500" images="
	https://placekitten.com/500/500,
	https://picsum.photos/id/1/500/500,
	https://via.placeholder.com/500,
	https://placebear.com/500/500,
	https://baconmockup.com/500/500
	">
</slide-show>

Now let's look at the JavaScript code. It's not terribly long so I'll share the whole bit, and then talk about what each part is doing:

class SlideShow extends HTMLElement {

	constructor() {

		super();

		const shadow = this.attachShadow({mode:'open'});

		if(!this.hasAttribute('images')) {
			console.warn('slide-show called with no images');
			return;
		}

		if(!this.hasAttribute('width')) {
			// default
			this.setAttribute('width', 500);
		}

		/*
		Convert attribute into an array and do some trimming so that the end user can have some spacing
		*/
		this.images = this.getAttribute('images').split(',').map(i => i.trim());
		
		// preload for quicker response, we don't need to wait for this
		this.preload(this.images);

		this.totalImages = this.images.length;

		this.current = 0;

		const wrapper = document.createElement('div');
		
		wrapper.innerHTML = `
		<img id="currentImage" src="${this.images[this.current]}">
		<p>
		<button id="prevButton">Previous</button> 
		Picture <span id="currentPicture">1</span> of ${this.totalImages}
		<button id="nextButton">Next</button> 
		</p>
		`;

		this.$nextButton = wrapper.querySelector('#nextButton');
		this.$prevButton = wrapper.querySelector('#prevButton');
		this.$currentPicture = wrapper.querySelector('#currentPicture');
		this.$image = wrapper.querySelector('#currentImage');
		
		const style = document.createElement('style');
		style.innerHTML = `
div {
	width: ${this.getAttribute('width')}px
}
p {
text-align: center;
}
		`;
		shadow.appendChild(wrapper);
		shadow.appendChild(style);
		
	}

	connectedCallback() {
		this.$nextButton.addEventListener('click', this.nextImage.bind(this));
		this.$prevButton.addEventListener('click', this.prevImage.bind(this));
	}

	nextImage() {
		if(this.current+1 == this.totalImages) return; 
		this.current++;
		this.updateImage();
	}

	prevImage() {
		if(this.current == 0) return; 
		this.current--;
		this.updateImage();
	}

	updateImage() {
		this.$image.src = this.images[this.current];
		this.$currentPicture.innerText = this.current+1;
	}

	preload(i) {
		for(let x=0; x<i.length; x++) {
			let img = new Image();
			img.src = i[x];
		}
	}
}

customElements.define('slide-show', SlideShow);

Alright, so from the top, I start with some basic validation. If you don't pass any images, the tag doesn't have anything to do so it might as well abort. I mentioned that the tag supports a width attribute and while it defaults, I would probably use it consistently in production. This part,

this.images = this.getAttribute('images').split(',').map(i => i.trim());

Is the bit that lets me add line breaks and stuff around the URLs. I really like this little bit as it makes it much easier for the developer to make use of the tag.

User experience FTW!

Speaking of experience, I added a preload function that automatically loads all the images. In theory, this will make the slideshow zippier as the user navigates through the images. I don't wait for it to finish, which I think is a good trade-off between trying to load things early and letting the user navigate as soon as they want.

Next up, I have the basic layout of the component. It's just an image with a paragraph beneath it. That paragraph contains my buttons as well as some text letting the user know what picture they're on as well as how many total images are available. I also create a style element with just a bit of layout control. This could be prettier. I don't do pretty.

That's most of the constructor explained. In the connectedBacllback event handler, I add my event listeners to the buttons, being careful to bind the this scope correctly and I totally didn't mess that up the first time around, honest. (I made a completely different mistake.) The event handlers do basic "end of range" checks and just update a value for the current image, then chaining off to updateImage to update the DOM.

You can see the entire thing in action below:

https://codepen.io/cfjedimaster/pen/eYjygxN?embedable=true

The source for this demo is up on my GitHub repo here

So, let me leave you with a few notes.


Also published here.