Building a petite-vue lightbox

Progressive Enhancement on the modern web

I recently decided to build a simple lightbox feature for my blog. In keeping with my original philosophy for the site, I wanted to avoid introducing unneeded complexity. I also wanted to ensure that there would be zero impact on users without JS enabled, in the spirit of progressive enhancement.

I’ve long been a fan of Vue.js, but introducing all of Vue for such a simple feature felt like overkill. So I was very excited when I first learned about something called “petite-vue”, an alternative distribution of the library which is designed for progressive-enhancement use cases (and comes in at around 7KB over the wire).

I could have just included jQuery and called it a day, or even written everything in plain JS (JavaScript as a language and the DOM APIs are both much better than they were 10 years ago). But something about the “Vue way” of working has always been very intuitive for me. This seemed like an opportunity to get the main benefits of Vue in a lightweight and unobtrusive package.

Let’s build a lightbox

A basic lightbox has three components:

  • The images embedded in an article which the user can click to view
  • The lightbox UI itself, which is likely some kind of modal
  • JS code that responds to user input, keeps track of the state of the UI, etc.

Since I wanted this feature to work as a progressive enhancement, I had one additional requirement:

  • This feature should have zero impact on what the user sees until JS initializes on the page; users without JS enabled should never notice that they are missing anything.

Setup

I believe that the era where most sites need JS build tools is coming to an end. Aside from a simple minification step, the code I write is the code my visitors will execute.

The “entry point” for the application is a single site.js file which is delivered via a <script type="module"> tag. Within that file, I can import the petite-vue library from a local copy I downloaded.

// site.js
import { createApp } from '../vendor/petite-vue.es.min.js';

Image Markup

Since this feature is a progressive enhancement, I’m relying on server-rendered elements for the underlying markup.

For the images themselves, that looks like this:

<img v-scope
	src="/images/my-image-800.jpg" 
	alt="Image alt"
	class="is-viewable fade-in" 
	loading="lazy"
	data-src="/images/my-image-1600.jpg" 
	data-lightbox="true"
	v-on:click="lightbox.display( '/images/my-image-1600.jpg' )"
/>

The initial src of each image points to a lower-resolution version (800px wide in this case). Some custom data- attributes have been added (a marker that the image is part of the slideshow and the path to a higher-resolution version of the image).

The v-on: attribute (in Vue terms, a directive) tells Vue how to handle click events; this works the same way it would in a standard Vue application. The v-scope attribute is unique to petite-vue however; this lets the library know that this part of the DOM will become part of the petite-vue application once it initializes. Petite-vue re-uses existing DOM elements and does not use a virtual DOM, so it helps to tell it what parts of the page it should interact with.

We need a place to actually display images when the user clicks something. This is our lighbox, and it sits near the end of the article markup, hidden by CSS until it gets activated.

This markup should look familiar to anyone who has worked with Vue.js before. The outer <div> element uses a reactive object to bind dynamic CSS classes (represented here as object keys). There is a click handler directive similar to above. In petite-vue, the mounted() lifecycle hook is replaced by a mounted event.

For the inner image element, the value of src is bound to a JS expression that will evaluate every time the reactive data changes. In this case, the expression is pretty simple: set src to the lightbox’s currentImage property or leave it as an empty string.

I originally just wrote v-bind:src="lightbox.currentImage" (which has a null value when the lightbox is inactive), but this was causing the image to make a bad GET requests with the string null in the path, which was annoying.

<div v-scope
	id="lightbox"
	class="lightbox"
	v-bind:class="{ 'is-active': lightbox.isActive }"
	v-on:click="lightbox.close"
	v-on:mounted="lightbox.mounted">

	<img 
		v-bind:src="lightbox.currentImage ? lightbox.currentImage : ''"
		loading="lazy"
	/>
</div>

This ties everything else together. As I mentioned earlier, this script is loaded on the page via a <script type="module"> tag. Using type=module means that the script won’t actually be executed until the rest of the document has finished loading; there is no need for anything like $(document).ready() to delay execution.

Additionally, module type scripts don’t pollute the global scope, so there is no need to put the code in an IIFE or UMD style wrapper. Have I mentioned yet that I am a huge fan of native JS modules?

A few notes about the code below. Overall this is pretty similar to the code you’d write in a traditional Vue.js Single-file component (especially the <script setup> variation, where you don’t need to break things up into data, computed, methods, etc.). But you don’t need to explicitly set properties as reactive; petite-vue does that automatically.

Computed properties are defined as getters, and methods are just object methods. Petite-vue doesn’t have true lifecycle hooks, but there is a mounted event which can be connected to a handler method like any other event in a component.

Minor differences aside, this is very similar to how you might represent this feature in a standard Vue.js app. There are reactive data properties to keep track of which image is currently being viewed, whether or not the slideshow is visible, methods to open and close, and a mounted method that initializes everything.

The biggest difference is where the data originates—in a typical Vue component, you’d expect to pass down props from a parent component or root instance. Petite-vue is more like jQuery here, working with the existing DOM element on the page. This approach is more amenable to progressive enhancement, where the server-rendered page is our “source of truth.”

Here is the code for our lightbox:

import { createApp } from '../vendor/petite-vue.es.min.js';

/**
 * Lightbox "module", handles lightbox display for article images.
 */
const lightbox = {
	/**
	 * Array of all participating IMG elements
	 * @type {Array<HTMLImageElement>|null}
	 */
	allImages: null,

	/**
	 * Path to the large version of the currently-active image
	 * @type {string|null}
	 */
	currentImage: null,

	/**
	 * @return {boolean}
	 */
	get isActive () {
		return !!this.currentImage
	},

	/**
	 * @return {number}
	 */
	get currentIndex () {
		return this.allImages.findIndex( img => {
			return img.dataset.src === this.currentImage;
		} );
	},

	/**
	 * Display an image in the lightbox
	 * @param {string} img
	 */
	display ( img ) {
		this.currentImage = img;
	},

	/**
	 * Clear the lightbox
	 */
	close () {
		this.currentImage = null;
	},

	/**
	 * Support moving to next/previous images via arrow keys and clearing
	 * lightbox via escape.
	 *
	 * @param {KeyboardEvent} event
	 * @returns
	 */
	onKeyPress ( event ) {
		if ( !this.isActive || !this.isReady ) return;

		/**
		 * @type {HTMLImageElement|undefined}
		 */
		const nextImage = this.allImages[ this.currentIndex + 1 ],
			prevImage = this.allImages[ this.currentIndex - 1 ];

		if ( event.key === "ArrowLeft" && prevImage ) {
			this.display( prevImage.dataset.src );
		}

		if ( event.key === "ArrowRight" && nextImage ) {
			this.display( nextImage.dataset.src );
		}

		if ( event.key === "Escape" ) {
			this.close();
		}
	},

	mounted () {
		this.allImages = Array.from( document.querySelectorAll( '*[data-lightbox]' ) );
		window.addEventListener( 'keyup', this.onKeyPress.bind( this ) );
	}
};

/**
 * Add each "module" as a property of the single object passed to createApp
 */
createApp( { lightbox: lightbox } ).mount();

Closing Thoughts

I love Vue.js and find it very intuitive to work with, but I think that for content-heavy sites progressive enhancement is generally a better fit than SPA-style architecture. Petite-vue can provide the best of both worlds here, and I think it fills an important niche in the ecosystem of modern UI frameworks. Plus it’s tiny (7KB) and can be used without any sort of a build step. This is great news for anyone who is tired of excessive front-end bloat and complexity.

I hope the project continues to be developed, and I encourage other developers (especially any fans of “old-school”, jQuery-style approaches) to give it a look!