Building an accessible image comparison web component

Written by Paul Hebert on

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


A friendly illustrated cloud character with eyes on a red-pink background.
A friendly illustrated cloud character with eyes surrounded by a variety of icons (including check marks, stars, code brackets, buttons, hearts, a checkbox, and many more) on a blue background.

The finished product is an open source web component called Image Compare. By leveraging native browser controls we were 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>

For more information you can view the docs, install it from npm, or view the source code on GitHub.

The challenge

Before we started, we needed to make sure we understood what we were building. The solution needed to:

  • 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, we should break it down into a series of smaller steps we can knock out one by one.

Layering the images

The HTML code for layering the images is 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>

By absolute-positioning the second image we can 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;
}

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%
  );
}

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%
  );
}

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>

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)
})

With that hooked up we can control our clipping path using the range input!

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 we 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>
.visually-hidden {
  border: 0; 
  clip: rect(0 0 0 0); 
  height: 1px; 
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
}

Positioning the slider

Next up, we need to make the slider full-width, and center it over the images. First we 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;
}

Then we need to make the input span the full width of the images. We 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 with our clip-path. 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));
}

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;
}

We can also give it a special CSS cursor to make its use more obvious:

.image-compare-input {
  cursor: col-resize;
}

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 */
}

(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 we apply to the thumbs. (We should also bump 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);
}

We should also add 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%);
}

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. We could add a new element for the divider and control its position using our --exposure custom property, but this would require the browser to update two different elements whenever the slider’s used.

Instead, let’s apply a shadow to the clipped image instead using the CSS drop-shadow filter. Using a drop-shadow filter requires 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>
.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%;
}

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%
  );
}

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:

  1. Our JavaScript only supports a single image comparison widget.
  2. If there’s other CSS on the page it could clash with our styles and break the widget.
  3. 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" });
  }

  connectedCallback() {
    // Apply our template markup
    this.shadowRoot.appendChild(template.content.cloneNode(true));

    // Add our event listener
    this.shadowRoot.querySelector("input").addEventListener('input', ({target}) => {
      this.shadowRoot.host.style.setProperty('--exposure', `${target.value}%`)
    });
  }
}

// Define our custom element so it can be used.
customElements.define("image-compare", ImageCompare);

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.

Paul Hebert

Paul Hebert is a hybrid designer and developer at Cloud Four. When he's not designing and developing websites he enjoys bouldering, drawing, cooking, gardening, and eating too much cheese.

Never miss an article!

Get Weekly Digests


Comments

Add a comment

This is extremely well written. Thank you for leveraging the native web APIs and showing how easy this could be.

Replies

Thanks Ahmad!

I’ve been enjoying the web component APIs and am looking forward to writing about them more!

I think a lot of the tooling around web components is really powerful, but makes web components seem more complicated than they are. I’m excited to incorporate more simple one-off web components into my work, and share them with the community.

Leave a Comment

Please be kind, courteous and constructive. You may use simple HTML or Markdown in your comments. All fields are required.


Let’s discuss your project! Email Us