Building an accessible image comparison web component
At 4/19/2024
For a recent article we wanted to allow readers to visually compare two images. There are lots of existing tools, but all of the ones we found had drawbacks that made us not want to use them on the blog:
- Some were inaccessible and impossible to control using a keyboard or screen reader.
- Some relied on other tools, like React or jQuery.
- Some loaded lots of JavaScript which could slow down our page loads.
It’s important that our blog posts are accessible and load quickly, so we decided to roll up our sleeves and write our own solution.
The Finished Product
The finished product is an open source web component called Image Compare. By leveraging native browser controls I was able to make it accessible, tiny (1.5kb gzipped and minified), and dependency-free.
Using Image Compare requires loading a script, and then passing in a couple images:
<script src="https://unpkg.com/@cloudfour/image-compare/dist/index.min.js"></script>
<image-compare>
<img slot="image-1" alt="Alt Text" src="path/to/image.jpg" />
<img slot="image-2" alt="Alt text" src="path/to/image.jpg" />
</image-compare>
Code language: HTML, XML (xml)
For more information you can view the docs, install it from npm, or view the source code on GitHub.
The challenge
Before I started, I needed to make sure I understood what I was building. My solution needed to do the following:
- Display two images layered on top of each other
- Allow viewers to drag a slider handle to control the visibility of the two images
- Ensure anyone can use the slider, whether they’re using a mouse, a touch screen, their keyboard, or an assistive technology like a screen reader
- Keep the solution small, quick loading, and high performing
Breaking it down
To tackle this, I broke it down into a series of smaller steps I could knock out one by one.
Layering the images
The HTML code for layering the images was pretty straightforward:
<div class="image-compare">
<img class="image-1" src="path/to/image" alt="alt text" />
<img class="image-2" src="path/to/image" alt="alt text" />
</div>
Code language: HTML, XML (xml)
By absolute-positioning the second image I could layer it on top of the first image:
/* Create a positioning context for the images */
.image-compare {
position: relative;
}
.image-2 {
display: block;
position: absolute;
top: 0;
}
Code language: CSS (css)
Now the second image is layered on top of the first image, but the first image is completely blocked. In order to obscure a portion of the second image we can use a css clip-path
.
.image-2 {
clip-path: polygon(50% 0, 100% 0, 100% 100%, 50% 100%);
}
Code language: CSS (css)
This clips the second image so only the right half is showing. However, we need to be able to dynamically update the percentage that is shown. Switching the 50%
to a custom property will make it easier to update the value with JavaScript.
.image-2 {
--exposure: 50%;
clip-path: polygon(
var(--exposure) 0,
100% 0,
100% 100%,
var(--exposure) 100%
);
}
Code language: CSS (css)
Adding a slider
Next up we need to add a way for users to adjust the clipping path on the top image. Luckily, browsers have a built-in control to pick a number between two values: range inputs!
<div class="image-compare">
<img class="image-1" src="path/to/image" alt="alt text" />
<img class="image-2" src="path/to/image" alt="alt text" />
<label class="image-compare-label">
Select what percentage of the bottom image to show
<input type="range" min="0" max="100" class="image-compare-input" />
</label>
</div>
Code language: HTML, XML (xml)
Now that we’ve added a slider, we need to hook it up to control our clip-path
. Since we’re using a native browser control to update a custom property, our JavaScript is pretty concise:
const clippedImage = document.querySelector(".image-2");
const clippingSlider = document.querySelector(".image-compare-input");
// Listen for the input being dragged
clippingSlider.addEventListener("input", (event) => {
// Grab the input's value
const newValue = `${event.target.value}%`;
// Use it to set our custom property
clippedImage.style.setProperty("--exposure", newValue);
});
Code language: JavaScript (javascript)
With that hooked up we can control our clipping path using the range input!
Andrey Gurtovoy mentioned in the comments on this article that using requestAnimationFrame
would be better for performance. We can use requestAnimationFrame
to allow the browser to decide when to repaint like so:
const clippedImage = document.querySelector(".image-2");
const clippingSlider = document.querySelector(".image-compare-input");
// Store an animation frame so we can keep track of scheduled
// repaints and cancel old repaints that haven't happened yet
let animationFrame;
// Listen for the input being dragged
clippingSlider.addEventListener("input", (event) => {
// If an animation frame is already queued up, cancel it
if (animationFrame) cancelAnimationFrame(animationFrame);
// Tell the browser to update our component when it is ready
// to repaint.
animationFrame = requestAnimationFrame(() => {
this.shadowRoot.host.style.setProperty(
"--exposure",
`${target.newValue}%`
);
});
});
Code language: JavaScript (javascript)
Styling the input
With a handful of lines of HTML, CSS, and JS, we’re 90% there! We can now visually compare two images using a slider. And, since we’re using native browser controls our code is accessible and performant! But, this doesn’t quite match the design I had in mind.
Visually hiding the label
It’s critical that the input has a label to provide context to screen reader users about what the input controls. But the label felt redundant in the context of the blog post. By applying some special CSS I can visually hide the label text while still exposing it to assistive technology like screen readers:
<label class="image-compare-label">
<span class="visually-hidden"
>Select what percentage of the bottom image to show</span
>
<input type="range" min="0" max="100" class="image-compare-input" />
</label>
Code language: HTML, XML (xml)
.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
Code language: CSS (css)
Positioning the slider
Next up, I want to make the slider full-width, and center it over the images. First we need to position the label over the images:
.image-compare-label {
/* Position the label over the images */
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
/* Stretch the input so it fills the label vertically */
align-items: stretch;
display: flex;
}
Code language: CSS (css)
Then we need to make the input span the full width of the images. I actually want the input to extend a little bit off the left and right edges, so that the center of the slider’s draggable “thumb” control will line up. For now we’ll estimate that the size of the thumb is 15 pixels, but we’ll circle back later to make sure this is consistent across browsers.
.image-compare-input {
--thumb-size: 15px;
/* Go half a "thumb" off the edge to the left and right" */
margin: 0 calc(var(--thumb-size) / -2);
/* Make the input a full "thumb" wider than 100% so it extends past the edges */
width: calc(100% + var(--thumb-size));
}
Code language: CSS (css)
Now we’re getting somewhere!
Hiding the slider bar
Now it’s time to customize the slider! First off, we need to remove the browser’s default styles and background:
.image-compare-input {
appearance: none;
-webkit-appearance: none;
background: none;
border: none;
}
Code language: CSS (css)
I’m also going to give it a special CSS cursor to make its use more obvious:
.image-compare-input {
cursor: col-resize;
}
Code language: CSS (css)
Now the bar behind the slider thumb is hidden, but the thumb control isn’t very obvious:
Styling the Thumb
Let’s make the thumb itself more obvious. To style range input thumbs we need to apply CSS to some funky browser-specific selectors. Due to how browsers parse selectors they don’t understand, these styles need to be applied separately for each browser engine:
/* Firefox */
.image-compare-input::-moz-range-thumb {
/* thumb styles */
}
.image-compare-input:focus::-moz-range-thumb {
/* thumb focus styles */
}
/* Chrome, Safari and Edge, */
:.image-compare-input: -webkit-slider-thumb {
-webkit-appearance: none;
/* thumb styles */
}
.image-compare-input:focus::-webkit-slider-thumb {
/* thumb focus styles */
}
Code language: CSS (css)
(The Open UI group is working on standardizing range inputs so hopefully they’ll be easier to style in the future.)
Here’s the full list of styles I ended up applying to thumbs. (I also bumped up the --thumb-size
custom property quite a bit.)
.image-compare-input::-funky-browser-specific-css-selector {
/* A white background with slight transparency */
background-color: hsla(0, 0%, 100%, 0.9);
/* An inline SVG of two arrows facing opposite directions */
background-image: url('data:image/svg+xml;utf8,<svg viewbox="0 0 60 60" width="60" height="60" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M20 20 L10 30 L20 40"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M40 20 L50 30 L40 40"/></svg>');
background-size: 90%;
background-position: center center;
background-repeat: no-repeat;
border-radius: 50%;
border: 2px hsla(0, 0%, 0%, 0.9) solid;
color: hsla(0, 0%, 0%, 0.9);
width: var(--thumb-size);
height: var(--thumb-size);
}
Code language: CSS (css)
I also added a box-shadow
outline when the input is focused:
.image-compare-input:focus::-funky-browser-specific-css-selector {
box-shadow: 0px 0px 0px 2px hsl(200, 100%, 80%);
}
Code language: CSS (css)
Now we’re getting somewhere:
Adding a vertical divider
However, the input thumb feels like it’s disconnected and floating over the images. Adding a vertical divider between the images could help anchor the thumb.
This divider will need to stick to the left side of the clipped image. I considered adding a new element for the divider and controlling its position using our --exposure
custom property, but then realized I could apply a shadow to the clipped image instead using the CSS drop-shadow
filter.
Using a drop-shadow
filter required adding a wrapper element around the clipped image (so that the drop shadow itself does not get clipped.)
<span class="image-2-wrapper">
<img class="image-2" src="path/to/image" alt="alt text" />
</span>
Code language: HTML, XML (xml)
.image-2-wrapper {
filter: drop-shadow(-2px 0 0 hsla(0, 0%, 0%, 0.9));
/*
Since CSS filters create a new positioning context,
we need to move some CSS rules from our image to the wrapper
*/
display: block;
position: absolute;
top: 0;
width: 100%;
}
Code language: CSS (css)
This works, but the divider is slightly off-center. We can adjust our clipping mask by a pixel (half the width of the divider) to fix this:
.image-2 {
clip-path: polygon(
calc(var(--exposure) + 1px) 0,
100% 0,
100% 100%,
calc(var(--exposure) + 1px) 100%
);
}
Code language: CSS (css)
Now the thumb feels more anchored:
Packaging it up
Now we’ve got a functioning image comparison widget, but there are a few issues that make it tricky to reuse:
- Our JavaScript only supports a single image comparison widget.
- If there’s other CSS on the page it could clash with our styles and break the widget.
- It requires you add a script and a stylesheet and use some pretty specific HTML markup.
Luckily, all of these problems can be solved by packaging it up as a web component! I’ll dive into this more in a follow-up post, but here’s a quick look at the code:
// Create a template to house our HTML markup
const template = document.createElement("template");
template.innerHTML = `
<style>
/* CSS Styles go here... */
</style>
<slot name="image-1"></slot>
<slot name="image-2"></slot>
<label>
<span class="visually-hidden js-label-text">
Select what percentage of the bottom image to show
</span>
<input type="range" value="50" min="0" max="100"/>
</label>
`;
class ImageCompare extends HTMLElement {
constructor() {
super();
// Use the Shadow DOM to scope CSS styles
this.attachShadow({ mode: "open" });
}
// Store an animation frame so we can keep track of scheduled
// repaints and cancel old repaints that haven't happened yet
animationFrame;
connectedCallback() {
// Apply our template markup
this.shadowRoot.appendChild(template.content.cloneNode(true));
// Add our event listener
this.shadowRoot
.querySelector("input")
.addEventListener("input", ({ target }) => {
// If an animation frame is already queued up, cancel it
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
// Tell the browser to update our component when it
// is ready to repaint.
this.animationFrame = requestAnimationFrame(() => {
this.shadowRoot.host.style.setProperty(
"--exposure",
`${target.value}%`
);
});
});
}
}
// Define our custom element so it can be used.
customElements.define("image-compare", ImageCompare);
Code language: JavaScript (javascript)
With a few extra lines of JavaScript, our widget is bundled up as a custom element which can be used with the <image-compare>
tag. Images can be passed in using slots, and our code is encapsulated to prevent conflicts with other CSS or JS using the Shadow DOM.
Browser controls to the rescue
When I started planning this component I felt a little overwhelmed. I had to handle input from mice, keyboards, touch screens, and screen readers. It had to be accessible and understandable by everyone. And it had to be performant. I considered going down a rabbit hole of adding mouse, touch, and keyboard event listeners to dynamically update a custom slider.
But then I remembered that browsers already had a control that did exactly what I needed. By using a native browser control, I could let the browser do the heavy lifting for me and focus on polishing the visual interface. With a little bit of CSS trickery, I could turn a range input and a couple images into exactly the interface I wanted.
Try it yourself!
Now that it’s packaged as a web component it’s easy to include in projects. Feel free to use it wherever. For more information you can view the docs, install it from npm, or view the source code on GitHub.